Legacy Migration Toolkit
On this page
Legacy Migration Toolkit is a set of four commands that move a running stack from its old layout into a new structure, across major versions of its database and its search engine. One exports everything from the old server; one imports it into the new structure, each store in the way its version allows; one verifies what actually arrived; and one renames module folders without losing the data that hangs off their names.
A migration like this cannot be a folder copy. PostgreSQL refuses to start on a data directory written by an older major version, a search-engine upgrade in place cannot be undone, and the records being moved include signing logs that must survive their full retention period.
At a glance
| Tools | export-legacy-data, import-legacy-data, verify-migration, rename-modules |
|---|---|
| Form | Command-line scripts |
| Language | Bash, driving Docker, pg_dump and pg_restore, the Elasticsearch snapshot API, rsync, and openssl |
| Status | Stable, used for a completed migration |
| Source code | Private |
What it does
A. Exports without changing the source's data
The export dumps each database, copies the object storage, takes a snapshot of the telemetry indices through the search engine's own API, and collects the runtime state that version control never held, such as a certificate authority's revocation list. It writes a manifest with a checksum and table count for every dump, the sizes, and the image versions of the source. It checks the free disk space first and warns when the object storage would not fit, and it can write to another disk or leave the object storage out. It locks the export folder to its owner. It checks that the source looks like the old production folder, and keeps the old layout's container names on purpose, with a note not to unify them, because the source does not change just because the target did. Databases are dumped in the custom format without owners or privileges, which tolerates a newer server and roles that differ there. The telemetry snapshot covers only the telemetry indices, without the cluster's global state; when the old search engine has no repository path, the export prints the exact compose lines and commands to add one, and the repository is handed to the engine's user and copied out through short-lived containers, because its files belong to that user rather than to mine. The revocation list is copied with a checksum, must parse as a valid DER file, is checked for expiry, and is explained when it has wrongly become a directory. Environment files and configuration are carried as reference only, not to be copied over the new structure, whose keys differ; the application's logs come along too. Copy progress is shown only on a terminal, so a log file does not fill with carriage returns.
B. Imports each store in the way its version allows
Databases are restored from their dumps into the new major version. The search engine restores the snapshot, after removing the data streams that would collide with it. The object storage is copied as files, because its format on disk carries forward. Cache and queue stores are left behind, since they are not a source of truth. The revocation list is put in place, after any directory left where it should be is removed, and its validity dates are printed. Databases are restored with clean, if-exists, no-owner, and no-privileges options, so a restore can be repeated safely. Bucket policies are applied again as a separate step; the run copies through a container when root-owned folders refuse a plain copy, and can be limited to databases, object storage, or telemetry for a migration in stages.
C. Refuses before it damages anything
The import validates every checksum before it touches a single database, so a damaged dump stops the run while the target is still intact. When the export recorded search indices too old for the new engine, it skips the telemetry restore and asks for a reindex first, while the other stages continue. After each restore it compares the number of tables with the number the export recorded and flags a difference for checking. A failure inside a stage — a database that never becomes ready within a minute, errors from the restore tool, a failed snapshot restore — is reported and the remaining stages continue; the verification command gives the final verdict.
D. Verifies what is really there
The verification command inspects the target rather than trusting that a script finished: data present in each store, files readable by the user each container runs as, folders owned by the user the search engine needs, and the telemetry user in place. One failed check does not stop the others, and a container that is down is reported as skipped rather than failed, so the command stays useful while a migration is done in stages. The container prefix is read from the environment file, not guessed from the folder name, after a guessed prefix that never existed turned every check into "skipped" and hid real damage. It confirms the database server is at least version 18, proof the major upgrade really happened, and that the revocation list is a valid, unexpired file rather than a missing one or a directory. File readability is checked for the two certificate authorities' configuration files: the user each image runs as is translated into its numeric id by asking inside the image, read from the compose file when the container does not exist yet, and checked before anything starts, so a wrong permission is found without waiting through thirty health-check retries. A module created after the old export has no entry in its manifest and only needs tables to exist. It ends with counts of passes, warnings, and failures, and exits with 1 when anything failed.
E. Renames modules without losing their data
Module folders once used underscores and now use hyphens. Since each module's data path contains its name, renaming a folder also moves where Docker looks for its data, so the rename command moves the data folder in the same step, and keeps the folder's history when version control tracks it. It covers development, production, and every staging version. Root access is checked before anything changes, after a password prompt that needed a terminal once left the project folder renamed and its data not. The data folder's size is compared before and after the move, and an existing destination is never overwritten. Each renamed module's compose project name is rewritten, so old and new are not treated as two projects. The project name falls back to the folder name when it is unset, after its absence once let a rename go ahead while development's containers were running. The table of names to rename carries a boxed warning against any global search-and-replace of underscores, after one such sweep made both columns equal and the rename quietly did nothing until production needed it. A key rewrite that finds nothing no longer ends the run after the first module, and paths are computed when used, so a data prefix set later is honoured.
How I use it
| Store | Old | New | How it moves |
|---|---|---|---|
| PostgreSQL | 14 | 18 | dump and restore; a data directory is tied to its major version |
| Elasticsearch | 8 | 9 | snapshot and restore; an in-place upgrade is one way only |
| Object storage | unpinned | pinned release | copy the folder; the format carries forward |
| Redis | unpinned | pinned release | not migrated; cache and queue only |
| Store | Its container must be | Why |
|---|---|---|
| Object storage | stopped | copying into a live storage folder can corrupt its backend |
| PostgreSQL | running | a restore needs a server that accepts connections |
| Elasticsearch | running | a snapshot is restored through its API |
Design decisions
Export and import are separate, and the source's data is never changed. The data usually has to be secured before the new structure is ready to receive it, so the two halves run on different days and meet through a folder. The export dumps, copies, and snapshots, and the only thing it adds beside the old data is the snapshot repository the search engine needs to write its snapshot. The manifest is what lets the import trust that folder: checksums are checked before any target database is dropped, table counts are compared after each restore, and the source's image versions are on record. The folder is readable by its owner alone, and the export warns before doubling tens of gigabytes of object storage on a disk that is too small.
Each store crosses the version gap in its own way. PostgreSQL 18 will not start on a directory written by 14, so the databases travel as dumps. Elasticsearch 9 reads only indices created by 8, and once it has touched a data folder version 8 can no longer read it, so the telemetry travels as a snapshot, which is portable across versions and leaves a point to return to. Indices created by version 7 are caught at export and stop the restore unless it is forced. The object storage only needs its files, and Redis, which holds a cache and a queue, is left behind.
It never starts or stops a stack itself. The stores need opposite states — object storage stopped, PostgreSQL and the search engine running — and the import checks each one. When a state is wrong, it stops and prints the command that would fix it, and it waits up to a minute for a database to accept connections before reporting that one and moving to the next. Control over a production stack stays with the person running the migration. The rename command follows the same rule, and refuses while any related container is still running.
Verification looks for the failures that look like success. A configuration file written readable only by its owner worked on my laptop, where my user id happened to be 1000, the same as the user inside the certificate authority's image; on a server with a different id the authority restarted endlessly, and two services that waited for it showed as unhealthy, pointing everywhere but the cause. A missing telemetry user is quieter still: every service stays healthy, the dashboards open, and every log, trace, and metric is dropped — which happened for two days in production. So the verification checks file readability against each container's user id, folder ownership, and the telemetry user, before anyone declares the migration done. The last check is mine: one document signed from start to finish.
Specifications
| Inputs | An old production folder to export; an export folder and a target environment to import into |
|---|---|
| Outputs | An export folder with dumps, object files, a search snapshot, runtime state, and a manifest; restored stores in the target; a verification report |
| Safety | Dry run for export, import, and rename; database-dump checksums validated before any target is dropped; table counts compared; never starts or stops a stack; data moved with its folder on rename, with sizes compared and no overwrite; failures inside export and import stages are reported, and verification gives the final verdict |
| Requirements | Docker; the target structure in place; disk space for the export, or a second disk; network access to pull small helper images at run time |
Related
The story of moving databases and a log archive across major versions.
The toolkit that takes over once the data has moved; its snapshot repository is one of the places the verification checks.
The toolkit that gives the target environment its network before anything is imported into it.
Each script follows the shape I give every small tool, in its Bash variant.
The toolkit that creates production's telemetry user — the step whose result the verification here checks.
Why a migration is treated like a recovery: a point to return to first, then the move.
Why telemetry that has lost its user is dropped without a word.
Terms
- Major version
- A release that may change on-disk formats, so data written by one major version cannot always be read by the next.
- Dump and restore
- Exporting a database as a portable file and loading it into another server, of the same or a newer version.
- Snapshot repository
- The location where Elasticsearch writes its snapshots, which another node can restore from.
- Manifest
- A file listing what an export contains, with a checksum for each database dump so damage can be detected before use.
- User id
- The number the kernel uses to decide who may read a file; names do not matter, only the number does.
- Data stream
- An append-only series of indices in Elasticsearch, used here for telemetry.
Technologies covered
- Bash
- Elasticsearch
- PostgreSQL
- Docker
- MinIO