> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lerian.studio/llms.txt
> Use this file to discover all available pages before exploring further.

# Database migrations

> Run Midaz Ledger and Tracer PostgreSQL schema migrations with dedicated runner images so applications boot against an already-migrated database.

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.

<Note>
  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.
</Note>

## 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](#security-posture)).

## Runner images

***

Two images are published per release:

| Image                     | Applies                                    | Databases                        |
| ------------------------- | ------------------------------------------ | -------------------------------- |
| `midaz-ledger-migrations` | Ledger onboarding + transaction migrations | Two: `onboarding`, `transaction` |
| `midaz-tracer-migrations` | Tracer schema                              | One                              |

Both images share the same shape:

* Base: [`migrate/migrate`](https://github.com/golang-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

| Database    | URL override               | Assembled from                                                                                                                                                                 |
| ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| onboarding  | `ONBOARDING_DATABASE_URL`  | `DB_ONBOARDING_HOST`, `DB_ONBOARDING_PORT` (default `5432`), `DB_ONBOARDING_USER`, `DB_ONBOARDING_PASSWORD`, `DB_ONBOARDING_NAME`, `DB_ONBOARDING_SSLMODE` (default `disable`) |
| transaction | `TRANSACTION_DATABASE_URL` | `DB_TRANSACTION_*` (same shape)                                                                                                                                                |

### Tracer runner

| URL override   | Assembled from                                                                                                |
| -------------- | ------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | `DB_HOST`, `DB_PORT` (default `5432`), `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_SSL_MODE` (default `disable`) |

### DSN shape

When the entrypoint assembles the DSN from `DB_*` variables, it produces:

```text theme={null}
postgres://<user>:<encoded-password>@<host>:<port>/<name>?sslmode=<sslmode>
```

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

| Target                     | Effect                                          |
| -------------------------- | ----------------------------------------------- |
| `make migrate`             | Applies onboarding then transaction (aggregate) |
| `make migrate-onboarding`  | Applies onboarding only                         |
| `make migrate-transaction` | Applies transaction only                        |
| `make migrate-down`        | Rolls back both databases                       |

Tracer (`components/tracer`):

| Target                 | Effect                                                  |
| ---------------------- | ------------------------------------------------------- |
| `make migrate`         | Applies the tracer schema                               |
| `make migrate-down`    | Rolls back the tracer schema                            |
| `make migrate-version` | Prints the current schema version                       |
| `make migrate-force`   | Forces the schema version (recovery from a dirty state) |

### 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:

```bash theme={null}
docker run --rm \
  --network=infra-network \
  -e DB_ONBOARDING_HOST=postgres \
  -e DB_ONBOARDING_PORT=5432 \
  -e DB_ONBOARDING_USER=midaz \
  -e DB_ONBOARDING_PASSWORD='s3cret!' \
  -e DB_ONBOARDING_NAME=onboarding \
  -e DB_ONBOARDING_SSLMODE=disable \
  -e DB_TRANSACTION_HOST=postgres \
  -e DB_TRANSACTION_PORT=5432 \
  -e DB_TRANSACTION_USER=midaz \
  -e DB_TRANSACTION_PASSWORD='s3cret!' \
  -e DB_TRANSACTION_NAME=transaction \
  -e DB_TRANSACTION_SSLMODE=disable \
  lerianstudio/midaz-ledger-migrations:<tag>
```

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.

<Note>
  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.
</Note>

### 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`-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 — 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.

## Related pages

***

* [Installing Midaz](/en/midaz/midaz-setup) — full local setup with `make up`.
* [Updating Midaz](/en/midaz/updating-midaz) — upgrade flows for local and Helm.
* [Deployment strategies](/en/midaz/deployment) — BYOC deployment overview.
* [Tracer environment variables](/en/tracer/tracer-environment-variables) — full Tracer configuration reference.
