> ## 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 the runner has already migrated.

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

## Runner images

***

Each release ships two images:

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

| 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

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

| 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, because 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 that matter 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 with that tenant's `DB_*` (or URL override) values.
</Note>

### 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 and host `make` targets all pin `golang-migrate` `v4.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:

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/products/midaz/midaz-setup): full local setup with `make up`.
* [Updating Midaz](/en/products/midaz/updating-midaz): upgrade flows for local and Helm.
* [Deployment strategies](/en/products/midaz/deployment): BYOC deployment overview.
* [Tracer environment variables](/en/products/tracer/tracer-environment-variables): full Tracer configuration reference.
