Skip to main content
Midaz applies PostgreSQL schema migrations through dedicated migration-runner images — one for the Ledger, one for Tracer — decoupled from the application binaries. The apps no longer migrate at startup: they boot against a schema that has already been migrated by the runner.
This model is what runs under make up in local development and under the Helm chart in Kubernetes. If you use make up, no manual step is required — 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 sidesteps that entirely:
  • 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


Two images are published per release: Both images share the same shape:
  • Base: migrate/migrate v4.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 — migration progress is tracked in the schema_migrations table in Postgres, so readOnlyRootFilesystem: true is safe.
The Ledger runner applies onboarding first, then transaction, in a single invocation. The Tracer runner applies its single database in one pass.

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 from DB_* variables, it produces:
The password is percent-encoded so URI-reserved characters (% @ : / ? # & + 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:
  1. Starts infra and waits until Postgres is healthy.
  2. Starts the one-shot ledger-migrate and tracer-migrate compose services.
  3. Starts each app service, which depends_on its migrate service with condition: service_completed_successfully.
The app never starts against an un-migrated database.

Host make targets

If you want to run migrations directly against a local database — for example when developing a new migration — each component exposes host-side Make targets that 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 — the compose gate does it for you. When you do, point the runner at your databases via DB_* (or *_DATABASE_URL) and run it once:
The runner exits 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 you should keep in mind 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, passing that tenant’s DB_* (or URL override) values.

Security posture

The runner is designed for 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-safemigrate writes no filesystem state.
The same image runs cleanly under runAsNonRoot: true, readOnlyRootFilesystem: true, and capabilities: drop [ALL].

TLS

The entrypoints honor sslmode 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 — TLS for migrations is controlled entirely through sslmode, in exchange for a minimal, dependency-free runner.

Conventions and idempotency


  • Paired up/down — every migration has a .up.sql and a .down.sql file.
  • Idempotent — running migrate up when 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 make targets all pin golang-migrate v4.19.1.

Upgrading from earlier versions


If you were running a Midaz release where the app migrated on startup, no manual data migration is required — the on-disk schema is the same. To adopt the new model:
  1. Pull the release that ships the runner images.
  2. In Kubernetes, add the midaz-ledger-migrations and midaz-tracer-migrations Jobs to your deploy pipeline so they run before the app rollout. If you use the official Helm chart, this is done for you.
  3. In local development, make up continues to work as before — the compose stack now gates the apps on the new one-shot migrate services automatically.