I Lost 3 Days Migrating TimescaleDB in Docker — Here's the Guide I Wish Existed
Volume mount mismatches, pg_dumpall skipping hypertable data, and continuous aggregates restored as regular views. Every trap I hit and the fix for each.
The safest way to migrate TimescaleDB in Docker is a three-step process: dump schema and regular tables with pg_dumpall, pipe hypertable data row-by-row using COPY (SELECT * FROM ...), and recreate continuous aggregates from scratch — because pg_dump silently restores them as plain views that lose all TimescaleDB functionality.
TL;DR: Migrating TimescaleDB in Docker requires three separate steps: pg_dumpall for schema and regular tables, COPY (SELECT * FROM hypertable) TO STDOUT for hypertable data (plain COPY returns 0 rows), and manual recreation of continuous aggregates. The timescaledb-ha image uses /home/postgres/pgdata/data — mounting to the wrong path triggers initdb and overwrites your volume.
TimescaleDB stores time-series data in internal chunks that standard PostgreSQL dump tools do not export. A plain COPY table_name TO STDOUT on a hypertable returns 0 rows because the data lives in child chunk tables, not the parent. You must use COPY (SELECT * FROM hypertable) TO STDOUT to force a full scan across all chunks.
Three additional traps will silently destroy data if you skip them:
Volume mount mismatch — the timescale/timescaledb-ha image uses PGDATA=/home/postgres/pgdata/data, not /var/lib/postgresql/data. Mounting to the wrong path runs initdb and overwrites your volume.
Continuous aggregates restore as regular views — they lose TimescaleDB-specific functionality and must be dropped and recreated.
POSTGRES_* env vars are ignored on existing volumes — they only run during the initial initdb.
Temps is a self-hosted deployment platform (Apache 2.0, ~$6/mo on Temps Cloud) that uses TimescaleDB as its internal backing store. It ships a custom Docker image — gotempsh/timescaledb-walg:pg18 — that bundles WAL-G for point-in-time recovery alongside the standard timescale/timescaledb-ha:pg18 image. This means Temps-managed TimescaleDB instances get automated backup and restore that handles hypertables correctly, instead of requiring the manual process below.
Temps self-hosts on TimescaleDB. The Temps server itself uses timescale/timescaledb-ha:pg18 as its internal database for storing analytics events, OTel spans, proxy logs, and metrics — all in TimescaleDB hypertables with continuous aggregates.
Temps ships a WAL-G-enhanced image. The gotempsh/timescaledb-walg:pg18 Docker image adds WAL-G binary to the standard TimescaleDB HA image, enabling automated streaming backups with point-in-time recovery — without requiring manual pg_dump migrations when upgrading.
The volume path bug is documented. Temps explicitly fixed the /home/postgres/pgdata/data volume mount path in its CHANGELOG — a real-world mistake that would silently run initdb and overwrite your database if you mount to /var/lib/postgresql/data instead.
docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "SELECT 'events' AS tbl, count(*) FROM eventsUNION ALL SELECT 'error_events', count(*) FROM error_eventsUNION ALL SELECT 'status_checks', count(*) FROM status_checks;"
Save these counts — you'll need them to verify the migration.
You'll see warnings about circular foreign-key constraints on hypertables, chunks, and continuous aggregates. These are normal and harmless.
pg_dumpall will NOT export data from TimescaleDB hypertables. The chunks are stored internally and regular COPY from the parent table returns 0 rows. We handle hypertable data separately in Step 6.
If you mount to the wrong path, the entrypoint runs initdb and creates a fresh empty cluster. Your volume gets overwritten with a blank database and your data is gone.
This is the step most guides miss. Hypertable data lives in internal chunks, so you need COPY (SELECT * FROM ...) syntax — a plain COPY table_name TO ... returns 0 rows.
Pipe data directly from old to new, one table at a time:
# eventsdocker exec OLD_CONTAINER psql -U YOUR_USER -d YOUR_DB \ -c "COPY (SELECT * FROM events) TO STDOUT WITH CSV HEADER" | \docker exec -i NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB \ -c "COPY events FROM STDIN WITH CSV HEADER"
# error_eventsdocker exec OLD_CONTAINER psql -U YOUR_USER -d YOUR_DB \ -c "COPY (SELECT * FROM error_events) TO STDOUT WITH CSV HEADER" | \docker exec -i NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB \ -c "COPY error_events FROM STDIN WITH CSV HEADER"
# status_checksdocker exec OLD_CONTAINER psql -U YOUR_USER -d YOUR_DB \ -c "COPY (SELECT * FROM status_checks) TO STDOUT WITH CSV HEADER" | \docker exec -i NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB \ -c "COPY status_checks FROM STDIN WITH CSV HEADER"
Repeat for every hypertable. If you get duplicate key errors, the pg_dumpall restore already inserted some data — truncate the table on the new container first, then re-run the COPY.
pg_dump restores continuous aggregates as regular views — they lose their TimescaleDB functionality. Drop them and recreate as proper continuous aggregates.
# Old containerdocker exec OLD_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "SELECT 'events' AS tbl, count(*) FROM eventsUNION ALL SELECT 'error_events', count(*) FROM error_eventsUNION ALL SELECT 'status_checks', count(*) FROM status_checks;"# New containerdocker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "SELECT 'events' AS tbl, count(*) FROM eventsUNION ALL SELECT 'error_events', count(*) FROM error_eventsUNION ALL SELECT 'status_checks', count(*) FROM status_checks;"
Counts should match (small differences are expected if your app is writing to both).
# Remove old containerdocker rm timescaledb-old# Remove old volume (only after you're sure!)docker volume rm old_timescaledb_data# Keep the backup for a weekls -lh /root/timescale_full_backup.sql.gz
The timescaledb-ha image runs timescaledb_tune on first init, but the POSTGRES_* env vars for tuning (like POSTGRES_SHARED_BUFFERS) are likely ignored. Tune via SQL instead:
docker exec timescaledb-1 psql -U YOUR_USER -d YOUR_DB -c "ALTER SYSTEM SET shared_buffers = '2GB';ALTER SYSTEM SET max_connections = 200;ALTER SYSTEM SET effective_cache_size = '3GB';ALTER SYSTEM SET work_mem = '16MB';ALTER SYSTEM SET maintenance_work_mem = '256MB';"docker restart timescaledb-1
Then verify:
docker exec timescaledb-1 psql -U YOUR_USER -d YOUR_DB -c "SHOW shared_buffers; SHOW max_connections; SHOW work_mem;"
If you are running TimescaleDB as part of a self-hosted deployment platform, Temps (Apache 2.0, ~$6/mo on Temps Cloud) eliminates the manual migration steps above. Temps uses TimescaleDB internally and ships the gotempsh/timescaledb-walg:pg18 image, which adds WAL-G streaming backups to the standard TimescaleDB HA image. Upgrading becomes a Temps console operation rather than a 10-step manual process.
Temps also handles git-push deployments, built-in web analytics (first-party localStorage with temps_visitor_id and session_id keys), session replay, error tracking, and uptime monitoring — all in a single Rust binary without per-seat fees or bandwidth bills.
Always verify your volume mount path. The timescale/timescaledb-ha image uses /home/postgres/pgdata/data, not /var/lib/postgresql/data. Getting this wrong means initdb runs and overwrites your volume.
pg_dumpall doesn't dump hypertable data. You must use COPY (SELECT * FROM hypertable) TO STDOUT to export hypertable data — plain COPY table_name TO STDOUT returns 0 rows because data lives in internal chunks.
Continuous aggregates need manual recreation. They're restored as regular views, not as TimescaleDB continuous aggregates. Drop and recreate them with the WITH (timescaledb.continuous) clause.
Run each continuous aggregate refresh separately.refresh_continuous_aggregate() cannot run inside a transaction block.
Keep the old container until you've fully verified. Don't docker rm it until you've compared row counts and confirmed the dashboard works.
POSTGRES_* tuning env vars may be ignored. The timescaledb-ha image runs timescaledb_tune which writes directly to postgresql.conf. Use ALTER SYSTEM SET for reliable configuration.
Always use a different port for the new container. Running both old and new simultaneously makes rollback trivial — just stop the new one and start the old one.
The timescale/timescaledb-ha image stores data at /home/postgres/pgdata/data. The standard timescale/timescaledb image uses /var/lib/postgresql/data. Mounting to the wrong path runs initdb and silently creates an empty database, overwriting your data.
Hypertable data is stored in internal chunk tables. A plain COPY hypertable TO STDOUT reads from the parent table definition, which has no rows — all rows live in the chunks. Use COPY (SELECT * FROM hypertable) TO STDOUT to execute a full scan across all chunks.
Yes. Temps ships the gotempsh/timescaledb-walg:pg18 image, which adds WAL-G backup and point-in-time restore to the standard TimescaleDB HA image. This eliminates the need for manual pg_dumpall + hypertable COPY migrations when upgrading. Temps is Apache 2.0 and free to self-host, or ~$6/mo on Temps Cloud (Hetzner cost + 30%, no per-seat fees).
No. pg_dump and pg_dumpall restore TimescaleDB continuous aggregates as regular materialized views. They lose the timescaledb.continuous property and stop refreshing automatically. You must drop the restored views and recreate them with CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous).