- Package your migration command into a separate Dockerfile whose
CMDruns the migration. - 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 examplemigrate/Dockerfile.
The only thing that makes it a “migration image” is its CMD - it runs migrations and exits, instead of starting a
server.
- Ruby on Rails
- Python (Django)
- Java (Flyway)
- .NET (EF Core)
migrate/Dockerfile
DATABASE_URL from the environment, or you can read the individual variables in
config/database.yml:config/database.yml
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 inops.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.
Using the dedicated migration image
If you built a separate migration image and pushed it to a registry, point the init job at it withimage:
ops.json
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 inops.json. Init jobs automatically receive, in this order:
- Secrets and variables you added for the service in the LocalOps console UI
exportsfrom clouddependencies(eg., an RDS instance)exportsfrompreviews.dependencieswhen running as part of a PR preview- Anything in the init job’s own
envblock
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 underpreviews.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.
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:- You push a commit (or click Deploy). LocalOps builds the new image.
- New pods are scheduled. In each new pod, the init containers run first - this is where your migration
cmdruns. Withonce: true, exactly one of those new pods actually performs the migration; the others wait for it to finish. - Once the migration exits successfully, the new pods start your application container.
- Health checks run against the new pods. As each becomes healthy, it starts taking traffic.
- Only then does Kubernetes terminate the old pods, which were serving the previous version the whole time.
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 aregion 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.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:- Ruby on Rails
- Python (Django)
lib/tasks/backfill.rake
backfill/Dockerfile
- 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 atbackfill, 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.
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 theapp-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: trueinit jobs instead. - Don’t mix DDL and long transactions. Some databases take heavy locks; prefer separate, small migrations.