Skip to main content
Most applications need to apply database schema changes before the new version of the code starts serving traffic. In LocalOps, you do this in two steps:
  1. Package your migration command into a separate Dockerfile whose CMD runs the migration.
  2. Run that image either as an init job in ops.json (automatic, on every deployment) or as a job service you trigger manually.
Use a migration framework with versioned, linear migration files (Rails ActiveRecord::Migration, Django migrations, Flyway, EF Core migrations, etc.,). To undo a change, add a new migration instead of editing an old one. This keeps migrations replayable across all your environments - production, dedicated, self-hosted and PR previews.

Step 1: Create a Dockerfile for migrations

Keep the migration image in its own directory in your repo, so you can point a job at it independently of your web service. For example migrate/Dockerfile. The only thing that makes it a “migration image” is its CMD - it runs migrations and exits, instead of starting a server.
migrate/Dockerfile
Rails builds DATABASE_URL from the environment, or you can read the individual variables in config/database.yml:
config/database.yml
If you also seed data, use a wrapper command like bundle exec rails db:migrate db:seed - or keep seeding as a second init job so you can control the order and see separate logs.
Commit the migration Dockerfile to the same repo as your service. Paths inside the Dockerfile (COPY ...) must resolve against the build context used for your service. Check the build logs of your first deployment if a COPY fails.

Step 2A: Run migrations automatically as an init job

This is the recommended option for most services. Declare the migration as an init job in ops.json. LocalOps runs init jobs before your main service starts, and fails the deployment if a job fails - so a broken migration never leaves you with new code running against an old schema. See Zero downtime deployments below for exactly where this lands in a rollout.
ops.json

Always set once: true for migrations

By default, an init job runs once per copy/pod of your service. If your service runs 3 containers, your migration would run 3 times in parallel. Setting once: true makes the job run exactly once across all pods for that deployment.
When once is set, cmd must be a fully qualified command (eg., bundle exec rails db:migrate), not just arguments. LocalOps overrides the ENTRYPOINT in your Dockerfile with a custom entrypoint script to get this behaviour.

Using the dedicated migration image

If you built a separate migration image and pushed it to a registry, point the init job at it with image:
ops.json
When you omit image, LocalOps uses the same image it just built from the latest commit in your branch/PR. That is usually what you want - the migrations are then always in lockstep with the code being deployed.

Environment variables

You don’t need to repeat your database credentials in ops.json. Init jobs automatically receive, in this order:
  1. Secrets and variables you added for the service in the LocalOps console UI
  2. exports from cloud dependencies (eg., an RDS instance)
  3. exports from previews.dependencies when running as part of a PR preview
  4. Anything in the init job’s own env block
So if your RDS dependency exports DB_HOST, DB_NAME and friends, the migration job can read them directly.

Migrations in PR preview environments

For ephemeral PR previews, declare an ephemeral database under previews.dependencies and the migration as an init job. LocalOps provisions the database first, then runs the init job - so every PR preview comes up with a freshly migrated schema.
ops.json

Step 2B: Run migrations manually as a job service

Sometimes you don’t want migrations tied to a deployment - for example a long-running backfill, a one-off data migration, or a production change that needs a maintenance window and an explicit human “go”. In that case, create a job service:

Add a new service

In your environment, go to the Services tab and click + Add new service. Pick Job as the kind.

Point at the migration Dockerfile

Set Dockerfile location to the directory holding your migration Dockerfile (eg., migrate). See Dockerfile location.

Leave the run command empty

The CMD in your migration Dockerfile is already the migration command, so nothing to override. If you reuse your main app’s Dockerfile instead, set Run command to eg., bundle exec rails db:migrate.

Add secrets

Add the database secrets the migration needs. If the database is provisioned via ops.json dependencies, its exports are injected automatically.

Deploy to run

Click Deploy to run the job. The container comes up, runs migrations and shuts down. Deploy again whenever you want to re-run it.
Turn off automatic deployments for this service so a push to your branch doesn’t silently run a migration. Then each run is an explicit, audited action from the LocalOps console.
You can create any number of job services - eg., one for schema migrations and another for a heavy data backfill.

Zero downtime deployments

Deployments in LocalOps use Kubernetes’ default rolling update strategy. When you deploy a new version, LocalOps does not stop your current containers first. Instead, it brings up new pods alongside the ones already serving traffic. Each new pod only starts receiving requests once it passes its health checks, and only after the new pods are healthy and serving does Kubernetes terminate the old ones. If the new pods never become healthy, the old ones keep serving and nothing is taken away from your users. Migrations declared as init jobs run inside this rollout, in the new pods, before any new application container starts. The sequence for a single deployment is:
  1. You push a commit (or click Deploy). LocalOps builds the new image.
  2. New pods are scheduled. In each new pod, the init containers run first - this is where your migration cmd runs. With once: true, exactly one of those new pods actually performs the migration; the others wait for it to finish.
  3. Once the migration exits successfully, the new pods start your application container.
  4. Health checks run against the new pods. As each becomes healthy, it starts taking traffic.
  5. Only then does Kubernetes terminate the old pods, which were serving the previous version the whole time.
The consequence worth internalising is in steps 3 and 4: your old containers are still serving traffic against the already-migrated schema. There is a window - from the moment the migration commits until the last old pod is terminated - where the previous version of your code is talking to the new database schema. This is exactly why migrations need to be backward compatible with the version being replaced.
A migration that renames or drops a column, or adds a NOT NULL column without a default, will break the old pods during this window - even though the deployment itself “succeeded”. Split such changes across two deployments: first deploy an additive, backward compatible migration (add the new column, write to both), then in a later deployment remove what is no longer read.
If a migration fails, the init container fails, the new pods never become ready, and the deployment is marked failed. Your old pods keep serving the previous version - so a bad migration costs you a failed deployment, not an outage. Do note that whatever the failed migration already committed to the database stays committed, so keep migrations small and let your framework’s transaction handling do its job.

Backfilling data

A schema migration changes the shape of a table and finishes in seconds. A backfill rewrites existing rows - possibly millions of them - and can run for minutes or hours. They deserve different treatment. Run backfills as a job service you trigger manually, not as an init job. An init job is the wrong home for a backfill because:
  • It blocks the rollout. Your deployment - and everything your team queues behind it - waits for every row to be rewritten.
  • It runs on every deployment. A one-time backfill would re-run on each deploy forever, until someone remembers to delete it from ops.json.
  • It couples two unrelated failures. A backfill that dies halfway fails the deployment, even though the schema change itself was fine.
  • You can’t pace it. As a job service you decide when it runs, watch it, stop it, and run it again.

The expand, backfill, contract sequence

Keep the schema change and the data change in separate deployments, with the backfill run in between. For adding a region column derived from an existing country_code:

Deploy 1 - expand

An init job migration adds region as a nullable column. Your new code writes region on every insert and update, but still reads country_code. Old pods, which know nothing about region, keep working - the column is nullable, so their inserts are still valid.

Run the backfill job

Trigger the backfill job service to fill region for pre-existing rows. Nothing is waiting on it, so it can take as long as it needs. Re-run it until it reports zero rows remaining.

Deploy 2 - contract

Now that every row has a value, deploy code that reads region, and an init job migration that adds the NOT NULL constraint and drops country_code.
Each deployment stays backward compatible with the version it replaces, which is what makes the whole sequence safe during a rolling update. See Zero downtime deployments.

Write backfills in batches

Never rewrite a whole table in one statement - it holds locks, bloats your write-ahead log and cannot be resumed if the container is restarted. Loop over small batches instead:
lib/tasks/backfill.rake
backfill/Dockerfile
Both examples share the four properties a good backfill needs:
  • Idempotent - the query selects only rows that still need work (region IS NULL), so a second run is cheap and a partial run is not a problem.
  • Resumable - each batch commits on its own. If the container is restarted, the next run picks up exactly where it stopped. No bookkeeping table needed.
  • Throttled - the pause between batches keeps database CPU and replication lag under control while your web service is still serving live traffic against the same database.
  • Observable - it logs progress as it goes, so you can watch it in Grafana instead of wondering whether it hung.

Running it

Create the backfill as its own job service - separate from your schema migration job - so the two have independent logs and independent run history. Point Dockerfile location at backfill, turn off automatic deployments, and click Deploy each time you want a run. Because the job is idempotent, “resume” and “run again” are the same action.
Keep the backfill job service around after the backfill finishes. A subsequent run is a no-op that costs one query, and it’s a cheap way to confirm nothing new slipped through while the expand deployment was live.
A backfill runs against the same database your live service is using. Start with a small BATCH_SIZE and a longer pause, watch database CPU and replication lag on the metrics dashboard, then raise the batch size if there is headroom.

Which option should I use?

A common setup is both: routine migrations as an init job, plus a separate job service for occasional backfills.

Viewing logs

Migration output shows up in the built-in Grafana dashboard. Go to the Monitor tab of your environment to sign in to Grafana, and filter logs for the app-services namespace. See Logs for more.

Good practices

  • Make migrations backward compatible. During a rollout, old and new containers run side by side for a short while. Add columns as nullable, backfill separately, and only drop columns in a later deployment. See Zero downtime deployments.
  • Keep migrations fast. Init jobs block the deployment. Move long backfills to a separate job service - see Backfilling data.
  • Make them idempotent and re-runnable. Init jobs run on every deployment, so a no-op second run must be safe. Most migration frameworks handle this with a schema-version table.
  • Avoid running migrations from your app’s entrypoint. Doing so runs them once per container, races between copies, and slows down every restart. Use once: true init jobs instead.
  • Don’t mix DDL and long transactions. Some databases take heavy locks; prefer separate, small migrations.