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 do not migrate at startup. They boot against a schema that the runner has already migrated.

Why a dedicated runner


The dedicated runner has these properties:
  • Migrations run once, as a separate step.
  • The app image does not bundle migration SQL and does not change the schema.
  • The runner image runs under a hardened security context (see Security posture).

Runner images


Each release ships two images: Both images share the same shape:
  • Base: migrate/migrate, at a pinned version.
  • 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_migrations table, 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 prints phase markers (applying onboarding migrations, applying transaction migrations, applying tracer migrations, migrations complete).

Run the Ledger runner manually


The Helm chart runs the runner for you. To run it by hand, point it at your databases via DB_* (or *_DATABASE_URL) and run it once before the ledger starts:
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 that matter if you author your own manifests.
The runner migrates the databases pointed at by its environment, once.

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: migrate 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. 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.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 use a pinned golang-migrate version.

Upgrading from earlier versions


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.