# Migrating TimescaleDB in Docker: The Missing Guide

> Migrate TimescaleDB between Docker containers safely: the pg_dump pre/post-restore path, the timescaledb-ha volume-path trap, and verifying hypertables.

David Viejo · Published 2026-03-25 · Updated 2026-10-20 · 10 min read

Canonical: https://temps.sh/blog/how-to-migrate-timescaledb-docker

To migrate TimescaleDB between Docker containers, dump the database with pg_dump -Fc, then on a new container with the same PostgreSQL and TimescaleDB major versions run CREATE EXTENSION timescaledb, SELECT timescaledb_pre_restore(), pg_restore, SELECT timescaledb_post_restore() and ANALYZE. Mount the timescaledb-ha volume at /home/postgres/pgdata/data, or initdb overwrites it.

> **TL;DR:** Use TimescaleDB's documented path: `pg_dump -Fc` the whole database, then on the new container run `CREATE EXTENSION timescaledb`, `SELECT timescaledb_pre_restore();`, `pg_restore`, `SELECT timescaledb_post_restore();` and `ANALYZE`. Source and target need the same PostgreSQL and TimescaleDB major versions. The traps sit around that path: the `timescaledb-ha` image keeps data in `/home/postgres/pgdata/data` (mount the wrong path and `initdb` overwrites your volume), a plain `COPY hypertable TO STDOUT` returns 0 rows, and restoring without `timescaledb_pre_restore()` is how hypertables and continuous aggregates come back broken.

---

## How Does TimescaleDB Migration in Docker Differ from Standard PostgreSQL?

TimescaleDB stores time-series data in internal chunk tables under each hypertable, plus catalog tables that tie them together. A whole-database `pg_dump` captures all of it, but the restore has to run with TimescaleDB in "restoring" mode, or its triggers and background jobs fight the restore. That is what `timescaledb_pre_restore()` and `timescaledb_post_restore()` are for. [Timescale's migration guide](https://www.tigerdata.com/docs/deploy/self-hosted/migration/entire-database) wraps every restore in them and recommends this approach for databases under about 100 GB.

Three traps will still destroy data if you skip them:

1. **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.
2. **Per-table `COPY` on a hypertable parent** — a plain `COPY table_name TO STDOUT` returns 0 rows because the rows live in the chunks. If you move data table by table, use `COPY (SELECT * FROM hypertable) TO STDOUT`.
3. **`POSTGRES_*` env vars are ignored on existing volumes** — they only run during the initial `initdb`.

### Which Migration Approach Should You Use?

| Approach                                                                                       | When to use it                                                                  | Hypertables                                           | Continuous aggregates                       |
| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------- |
| `pg_dump -Fc` + `pg_restore` inside `timescaledb_pre_restore()` / `timescaledb_post_restore()` | Moving a whole database between containers with matching versions (the default) | Restored with their chunks                            | Restored with the database                  |
| Per-table `COPY (SELECT * FROM ...)`                                                           | Moving only some tables, or when the full restore isn't possible                | Copied row by row into a hypertable you created first | Recreate them yourself                      |
| Plain-SQL `pg_dumpall` replayed with `psql`, no pre/post restore                               | Avoid for TimescaleDB data                                                      | In our run, hypertable data didn't come across        | In our run, they came back as regular views |

---

## Prerequisites

- A running TimescaleDB container (source)
- Docker installed on the target host
- Enough disk space for the dump (check your database sizes first)

---

## Step 1: Assess Your Database

Before doing anything, understand what you're working with.

### Check database sizes

```bash
docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT pg_database.datname, pg_size_pretty(pg_database_size(pg_database.datname))
FROM pg_database ORDER BY pg_database_size(pg_database.datname) DESC;"
```

### Identify your hypertables

```bash
docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT hypertable_name FROM timescaledb_information.hypertables;"
```

### Check table sizes and find bloat

```bash
docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT
  schemaname || '.' || tablename AS table,
  pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
  pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS data_size,
  pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename) - pg_relation_size(schemaname || '.' || tablename)) AS index_size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal')
ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC
LIMIT 20;"
```

### Count rows in hypertables

```bash
docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT 'events' AS tbl, count(*) FROM events
UNION ALL SELECT 'error_events', count(*) FROM error_events
UNION ALL SELECT 'status_checks', count(*) FROM status_checks;"
```

Save these counts — you'll need them to verify the migration.

---

## Step 2: Clean Up Before Migrating

This step is optional. Large tables with old data slow a migration, so trim what you genuinely don't need. The table and column names below (`session_replay_events`, `timestamp`) are from the Temps control-plane database; substitute your own hypertable and time column.

```bash
# Check how much you can trim (example: session replay events older than 15 days)
docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT
  count(*) FILTER (WHERE timestamp < extract(epoch from now() - interval '15 days') * 1000) AS to_delete,
  count(*) FILTER (WHERE timestamp >= extract(epoch from now() - interval '15 days') * 1000) AS to_keep
FROM session_replay_events;"

# Optional and destructive: only if you really don't need the data
# (back up first; prefer deleting rows older than your cutoff over TRUNCATE)
# docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "TRUNCATE YOUR_TABLE;"
```

---

## Step 3: Dump the Database with pg\_dump

First, confirm both sides will run the same major versions. The new container must use the same PostgreSQL major version and the same TimescaleDB major version as the old one:

```bash
docker exec YOUR_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT version();
SELECT extversion FROM pg_extension WHERE extname = 'timescaledb';"
```

Dump the database in custom format (`-Fc`), which `pg_restore` needs. `pg_dump` doesn't include roles, so dump those separately:

```bash
docker exec YOUR_CONTAINER pg_dump -U YOUR_USER -Fc -d YOUR_DB > /root/timescale_backup.dump
docker exec YOUR_CONTAINER pg_dumpall -U YOUR_USER --roles-only > /root/timescale_roles.sql
ls -lh /root/timescale_backup.dump
```

You may see warnings about circular foreign-key constraints on hypertables, chunks and continuous aggregates. TimescaleDB's catalog references itself, so these are expected for a TimescaleDB dump.

---

## Step 4: Create the New Container

Create a fresh volume and container. Pay attention to the volume mount path — this is where most people get burned.

```bash
# Create a new volume
docker volume create timescaledb_data_v2

# Start the new container
docker run -d \
  --name timescaledb-new \
  --network YOUR_NETWORK \
  --restart unless-stopped \
  --shm-size 2g \
  -p 5453:5432 \
  -v timescaledb_data_v2:/home/postgres/pgdata/data \
  -e POSTGRES_USER=YOUR_USER \
  -e POSTGRES_PASSWORD='YOUR_PASSWORD' \
  -e POSTGRES_DB=YOUR_DB \
  -e POSTGRES_HOST_AUTH_METHOD=scram-sha-256 \
  -e TIMESCALEDB_TELEMETRY=off \
  --health-cmd 'pg_isready -U YOUR_USER -d YOUR_DB' \
  --health-interval 10s \
  --health-timeout 5s \
  --health-start-period 60s \
  --health-retries 5 \
  timescale/timescaledb-ha:pg18   # use the same Postgres major as your source
```

### Critical: Volume Mount Path

| Image                      | Correct Mount Path           |
| -------------------------- | ---------------------------- |
| `timescale/timescaledb-ha` | `/home/postgres/pgdata/data` |
| `timescale/timescaledb`    | `/var/lib/postgresql/data`   |

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.

### Use a different port

Mount on a different port (e.g., `5453` instead of `5452`) so both containers can coexist. This gives you an easy rollback path.

Wait for the container to be healthy:

```bash
docker inspect --format='{{.State.Health.Status}}' timescaledb-new
```

---

## Step 5: Restore with timescaledb\_pre\_restore()

Recreate any roles your application needs, enable the extension, and put the database into restoring mode:

```bash
# Roles (errors such as "role already exists" for the POSTGRES_USER role are expected)
docker exec -i timescaledb-new psql -U YOUR_USER -d YOUR_DB < /root/timescale_roles.sql

docker exec timescaledb-new psql -U YOUR_USER -d YOUR_DB -c "
CREATE EXTENSION IF NOT EXISTS timescaledb;
SELECT timescaledb_pre_restore();"
```

`timescaledb_pre_restore()` switches the database into restoring mode and stops TimescaleDB's background workers, so the restore doesn't trip over its own triggers and jobs. Now restore the dump and switch the database back:

```bash
docker exec -i timescaledb-new pg_restore -U YOUR_USER --no-owner -v -d YOUR_DB < /root/timescale_backup.dump

docker exec timescaledb-new psql -U YOUR_USER -d YOUR_DB -c "SELECT timescaledb_post_restore();"
docker exec timescaledb-new psql -U YOUR_USER -d YOUR_DB -c "ANALYZE;"
```

Don't skip `timescaledb_post_restore()`: until it runs, background jobs such as continuous-aggregate refresh policies and compression stay stopped.

---

## Step 6 (Fallback): Copy Hypertable Data Table by Table

Use this only when you can't do the full restore — for example, you're moving a few hypertables into a database that already has a schema. Create the hypertables on the new side first, then pipe the data across. Hypertable data lives in internal chunks, so you need `COPY (SELECT * FROM ...)` syntax — a plain `COPY table_name TO ...` returns 0 rows.

```bash
# events
docker 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"
```

```bash
# error_events
docker 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"
```

Repeat for every hypertable you're moving. If you get duplicate key errors, the table already has rows — truncate it on the new container first, then re-run the COPY.

```bash
docker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "TRUNCATE table_name;"
```

---

## Step 7: Check Continuous Aggregates

After a full restore with pre/post restore, list the continuous aggregates and compare them with the old database:

```bash
docker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT view_name, materialized_only FROM timescaledb_information.continuous_aggregates;"
```

If one is missing from that list but exists as a regular view — which is what we got when we first replayed a plain-SQL `pg_dumpall` file with `psql` and no pre/post restore — or if you used the per-table fallback, recreate it.

### Drop the regular view

```bash
docker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
DROP VIEW IF EXISTS events_hourly CASCADE;"
```

Use `DROP VIEW` here, not `DROP MATERIALIZED VIEW` — it exists as a regular view.

### Recreate as a continuous aggregate

```bash
docker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
CREATE MATERIALIZED VIEW events_hourly
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', timestamp) AS bucket,
    project_id,
    COUNT(DISTINCT visitor_id) FILTER (WHERE visitor_id IS NOT NULL) AS unique_visitors,
    COUNT(DISTINCT session_id) AS unique_sessions,
    COUNT(*) FILTER (WHERE event_type = 'page_view') AS page_views,
    COUNT(*) AS total_events
FROM events
GROUP BY bucket, project_id
WITH NO DATA;

SELECT add_continuous_aggregate_policy('events_hourly',
    start_offset => INTERVAL '3 hours',
    end_offset => INTERVAL '1 hour',
    schedule_interval => INTERVAL '10 minutes');"
```

Run each `CREATE MATERIALIZED VIEW` as a separate command — they can't be batched in a single transaction.

### Backfill historical data

```bash
docker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB \
  -c "CALL refresh_continuous_aggregate('events_hourly', NOW() - INTERVAL '30 days', NOW());"
```

Run each refresh separately — `refresh_continuous_aggregate()` cannot run inside a transaction block.

---

## Step 8: Verify

Compare row counts between old and new:

```bash
# Old container
docker exec OLD_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT 'events' AS tbl, count(*) FROM events
UNION ALL SELECT 'error_events', count(*) FROM error_events
UNION ALL SELECT 'status_checks', count(*) FROM status_checks;"

# New container
docker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT 'events' AS tbl, count(*) FROM events
UNION ALL SELECT 'error_events', count(*) FROM error_events
UNION ALL SELECT 'status_checks', count(*) FROM status_checks;"
```

Counts should match (small differences are expected if your app is writing to both).

Verify the hypertables came across:

```bash
docker exec NEW_CONTAINER psql -U YOUR_USER -d YOUR_DB -c "
SELECT hypertable_name, num_chunks FROM timescaledb_information.hypertables;"
```

Verify data is actually on the volume:

```bash
docker run --rm -v timescaledb_data_v2:/data alpine du -sh /data
```

This should show a size proportional to your database — not 70MB (which would indicate a fresh `initdb`).

---

## Step 9: Swap to Production

Once verified, swap the new container to the production port:

```bash
docker stop OLD_CONTAINER
docker rename OLD_CONTAINER timescaledb-old

docker stop NEW_CONTAINER
docker rm NEW_CONTAINER

docker run -d \
  --name timescaledb-1 \
  --network YOUR_NETWORK \
  --restart unless-stopped \
  --shm-size 2g \
  -p 5452:5432 \
  -v timescaledb_data_v2:/home/postgres/pgdata/data \
  -e POSTGRES_USER=YOUR_USER \
  -e POSTGRES_PASSWORD='YOUR_PASSWORD' \
  -e POSTGRES_DB=YOUR_DB \
  -e POSTGRES_HOST_AUTH_METHOD=scram-sha-256 \
  -e TIMESCALEDB_TELEMETRY=off \
  --health-cmd 'pg_isready -U YOUR_USER -d YOUR_DB' \
  --health-interval 10s \
  --health-timeout 5s \
  --health-start-period 60s \
  --health-retries 5 \
  timescale/timescaledb-ha:pg18   # use the same Postgres major as your source
```

---

## Step 10: Clean Up

Once everything is confirmed working:

```bash
# Remove old container
docker rm timescaledb-old

# Remove old volume (only after you're sure!)
docker volume rm YOUR_OLD_DATA_VOLUME   # the volume mounted by the old container (see `docker inspect timescaledb-old`)

# Keep the backup for a week
ls -lh /root/timescale_backup.dump
```

---

## Post-Migration: Tune PostgreSQL

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:

```bash
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:

```bash
docker exec timescaledb-1 psql -U YOUR_USER -d YOUR_DB -c "
SHOW shared_buffers; SHOW max_connections; SHOW work_mem;"
```

---

## Running TimescaleDB on Temps

[Temps](https://temps.sh) is a free, self-hosted deployment platform (MIT / Apache 2.0) that runs on TimescaleDB itself: its control-plane database stores analytics events, traces, proxy logs and metrics in hypertables. If you'd rather not hand-roll the container side, Temps' [managed PostgreSQL](https://temps.sh/docs/managed-postgresql) can run the `timescale/timescaledb-ha:pg18   # use the same Postgres major as your source` image or `gotempsh/timescaledb-walg:pg18`, which adds WAL-G. With a WAL-G image, [scheduled backups](https://temps.sh/docs/backups) take a base backup plus a continuous WAL archive to your S3 target, and you can [restore into a new service or to a point in time](https://temps.sh/docs/restore-and-recovery). Moving an existing database into a Temps-managed service is still the pg\_dump / pre-restore / pg\_restore procedure above.

Temps also handles git-push deployments, web analytics, session replay, error tracking and uptime monitoring in one binary, without per-seat fees. Install it with:

```bash
curl -fsSL https://temps.sh/deploy.sh | bash
```

If you're weighing TimescaleDB against ClickHouse for analytics data in the first place, see [TimescaleDB vs ClickHouse](https://temps.sh/blog/timescaledb-vs-clickhouse-devops-mind); for routine dumps, see [how to back up PostgreSQL in Docker automatically](https://temps.sh/blog/how-to-back-up-postgresql-in-docker-automatically).

---

## Lessons Learned

1. **Use the documented restore path.** `pg_dump -Fc`, then `timescaledb_pre_restore()`, `pg_restore`, `timescaledb_post_restore()` and `ANALYZE`, with matching PostgreSQL and TimescaleDB major versions on both sides.

2. **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.

3. **Don't `COPY` from a hypertable parent.** Plain `COPY table_name TO STDOUT` returns 0 rows because data lives in internal chunks. Use `COPY (SELECT * FROM hypertable) TO STDOUT` when moving tables individually.

4. **Check continuous aggregates after the restore.** If one came back as a regular view, drop it and recreate it with the `WITH (timescaledb.continuous)` clause, then refresh each one separately — `refresh_continuous_aggregate()` cannot run inside a transaction block.

5. **Keep the old container until you've fully verified.** Don't `docker rm` it until you've compared row counts and confirmed the application works.

6. **`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.

7. **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.

---

## FAQ

### How do you migrate a TimescaleDB database to a new Docker container?

Dump it with `pg_dump -Fc`, start the new container with the same PostgreSQL and TimescaleDB major versions, run `CREATE EXTENSION IF NOT EXISTS timescaledb;` and `SELECT timescaledb_pre_restore();`, restore with `pg_restore --no-owner`, then run `SELECT timescaledb_post_restore();` and `ANALYZE;`. That is the procedure in [Timescale's migration guide](https://www.tigerdata.com/docs/deploy/self-hosted/migration/entire-database).

### What do timescaledb\_pre\_restore() and timescaledb\_post\_restore() do?

`timescaledb_pre_restore()` puts the database into restoring mode and stops TimescaleDB's background workers so `pg_restore` can load the catalog, hypertables and chunks without triggers and jobs interfering. `timescaledb_post_restore()` turns restoring mode off and restarts the background workers, so refresh, compression and retention policies run again.

### What is the correct volume path for timescale/timescaledb-ha?

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.

### Why does COPY from a TimescaleDB hypertable return 0 rows?

Hypertable data is stored in internal chunk tables. A plain `COPY hypertable TO STDOUT` reads only the parent table, which has no rows — all rows live in the chunks. Use `COPY (SELECT * FROM hypertable) TO STDOUT` to scan across all chunks.

### Do continuous aggregates survive a pg\_dump restore?

They are part of the database that `pg_dump` captures, and Timescale's documented pre/post-restore procedure migrates the entire database. Check `timescaledb_information.continuous_aggregates` afterwards anyway: when we replayed a plain-SQL `pg_dumpall` file with `psql` and no pre/post restore, our aggregates came back as regular views and had to be recreated with `CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)`.

---

Source: https://temps.sh/blog/how-to-migrate-timescaledb-docker · Index of all pages: https://temps.sh/llms.txt
