This model is what runs under
make up in local development and under the Helm chart in Kubernetes. If you use make up, you need no manual step. The compose stack gates the app on the migration runner.Why a dedicated runner
The old in-process migrator ran on every app boot from the working directory. Under a hardened pod security context (
distroless:nonroot, runAsUser: 1000, readOnlyRootFilesystem: true, capabilities: drop [ALL]), that path failed with permission denied and crash-looped the pod.
The dedicated runner avoids that:
- Migrations run once, as a separate step, before the app starts.
- The app image no longer bundles migration SQL and never writes to the schema.
- The runner image itself runs cleanly under the same hardened security context (see Security posture).
Runner images
Each release ships two images:
Both images share the same shape:
- Base:
migrate/migratev4.19.1(pinned). - Runs as
USER 65532:65532(non-root, UID-agnostic). - POSIX shell entrypoint that assembles a DSN from environment variables, runs
migrate ... up, and exits. - Writes nothing to disk. Postgres tracks migration progress in the
schema_migrationstable, soreadOnlyRootFilesystem: trueis safe.
Environment contract
Each entrypoint accepts either a prebuilt URL override or the individual
DB_* variables. The URL override takes precedence when set.
Ledger runner
Tracer runner
DSN shape
When the entrypoint assembles the DSN fromDB_* variables, it produces:
% @ : / ? # & + space [ ]) do not break the DSN. % is encoded first so already-inserted escapes are not double-encoded.
The entrypoint logs only phase markers (applying onboarding migrations, applying transaction migrations, applying tracer migrations, migrations complete). It never prints credentials or the assembled DSN.
Local development
make up
From the repository root, make up:
- Starts infra and waits until Postgres is healthy.
- Starts the one-shot
ledger-migrateandtracer-migratecompose services. - Starts each app service, which
depends_onits migrate service withcondition: service_completed_successfully.
Host make targets
Each component exposes host-side Make targets that run migrations directly against a local database, for example when you develop a new migration. The targets use the pinned golang-migrate CLI and read connection settings from .env.
Ledger (components/ledger):
Tracer (
components/tracer):
Example: run the Ledger runner manually
You rarely need to invoke the runner image by hand, because the compose gate does it for you. When you do, point the runner at your databases viaDB_* (or *_DATABASE_URL) and run it once:
0 on success (including the idempotent no-op case) and non-zero on failure.
Kubernetes deployment
In production, run each migration runner as a Kubernetes Job (typically an Argo CD PreSync Job or a Helm hook) that completes before the app rollout. The Helm chart wires this up for you. The sections below cover the properties that matter if you author your own manifests.
For multi-tenant deployments, the runner is intentionally tenant-agnostic: it migrates the databases pointed at by its environment, once. Per-tenant fan-out is a deploy concern. Run the Job once per tenant database with that tenant’s
DB_* (or URL override) values.Security posture
The runner works under a hardened pod security context:- Non-root: the image sets
USER 65532:65532. - UID-agnostic: migration files are root-owned and world-readable, so any UID can read them.
readOnlyRootFilesystem-safe:migratewrites no filesystem state.
runAsNonRoot: true, readOnlyRootFilesystem: true, and capabilities: drop [ALL].
TLS
The entrypoints honorsslmode from the environment (DB_ONBOARDING_SSLMODE, DB_TRANSACTION_SSLMODE, or DB_SSL_MODE), defaulting to disable. Production deployments should set require (or stricter). The shell runner does not reproduce the in-code TLS enforcement, error classification, or telemetry that the apps use for their own connections. You control TLS for migrations through sslmode alone, in exchange for a minimal, dependency-free runner.
Conventions and idempotency
- Paired up/down: every migration has a
.up.sqland a.down.sqlfile. - Idempotent: running
migrate upwhen the schema is already at the latest version is a no-op. Re-running the runner after a partial or complete apply is safe. - Pinned CLI: the runner images and host
maketargets all pingolang-migratev4.19.1.
Upgrading from earlier versions
You need no manual data migration if you upgrade from a Midaz release where the app migrated on startup. The on-disk schema is the same. To adopt the new model:
- Pull the release that ships the runner images.
- In Kubernetes, add the
midaz-ledger-migrationsandmidaz-tracer-migrationsJobs to your deploy pipeline so they run before the app rollout. If you use the official Helm chart, this is done for you. - In local development,
make upcontinues to work as before. The compose stack now gates the apps on the new one-shot migrate services automatically.
Related pages
- Installing Midaz: full local setup with
make up. - Updating Midaz: upgrade flows for local and Helm.
- Deployment strategies: BYOC deployment overview.
- Tracer environment variables: full Tracer configuration reference.

