Skip to main content
Most frameworks ship a command that applies pending schema changes — rails db:migrate, prisma migrate deploy, alembic upgrade head, php artisan migrate. The question is where in the deploy that command runs. Two things matter:
  • It must run once per release, not once per task or once per service.
  • It must run against the target database, from inside the VPC, with the same credentials as the app.
Ravion gives you three places that satisfy both. Which one to pick depends mostly on whether Ravion builds your image. If Ravion builds your image with Railpack or a Dockerfile, run the migration as the last build command. The build runner is already running, so the migration adds only its own execution time to the deploy — no container to schedule, pull, and tear down. Builds run on EC2 in your AWS account, inside the module’s builder execution environment (see rvn-ecs-web; by default, the same network placement as the ECS cluster). That is what lets the runner reach a private RDS instance. You need to give it two things it does not get by default:
  1. The connection string. Add DATABASE_URL to build_environment_variables, referenced from Secrets Manager rather than pasted in. Build variables are separate from runtime variables, so the value has to be declared in both places.
  2. Network access to the database. Allow the build runner’s security group or subnet CIDR in the RDS module’s allowed_security_group_ids or allowed_cidr_blocks. If the runner is in the same VPC, the VPC CIDR is the simplest rule.
ravion.yaml
For a Dockerfile build, enable dockerfile_environment_variable_injection_enabled so build variables arrive as build arguments, declare ARG DATABASE_URL and run the migration in a RUN instruction that uses it directly. Do not copy it into an ENV, or the connection string is baked into the image. Trade-offs to be aware of:
  • The build environment has production database access. A compromised build step can reach your database. Keep the build variables scoped to the one service that owns the schema, and use a database user that can alter schema but nothing more than the app needs.
  • The migration runs before the image is deployed. If the deploy itself later fails (health checks, capacity), the schema has already moved. This is the same window a pre-deploy hook has, just earlier; backward-compatible migrations (below) make it safe.
  • Builds are no longer reproducible on their own. Rebuilding an old commit reruns its migrations, which is a no-op for frameworks that track applied migrations but worth knowing.
  • One service per environment. If a web and a worker both build from the same repo, put the migration in only one of the two build configs — otherwise both runners race for the migration lock.
When you deploy an image Ravion did not build — build_source: ecr or image_registry — there is no build runner to piggyback on, so use the pre-deploy hook instead. rvn-ecs-web and rvn-ecs-worker can start a one-off ECS task before the service rolls out. The task uses the image that is about to be deployed, the same task role, subnets, security groups, environment variables, and secrets as the service, so it reaches the database exactly the way your app does without any extra configuration. If the command exits non-zero, the deploy is aborted and the running tasks are left untouched.
ravion.yaml
The command is an ECS argument array, not a shell string. Wrap it in /bin/sh -lc when you need shell features such as && or environment variable expansion. Hooks accept their own pre_deploy_environment_variables, pre_deploy_cpu, pre_deploy_memory, pre_deploy_ephemeral_storage_size_gib, and pre_deploy_timeout, so a heavy backfill can get more resources than the steady-state web task without changing the service. The cost is time: every deploy waits for ECS to place the task, pull the image, run the command, and stop, typically a minute or more on top of the migration itself. If Ravion builds your image and deploy speed matters, prefer option 1. Pre-deploy is still the right choice when you do not want the build environment to have database access.
Enable the hook on one service per environment — usually the web service. A web and a worker deployed from the same image both running db:migrate at the same time is the most common cause of migration lock errors.

Option 3: A pipeline step

When migrations need to run before several services deploy, need to run from a different image than the app, or should be gated by an approval, use a custom step in your pipeline. Steps in a list run in order, so place it between the build and the deploys:
pipeline.yaml
Pipeline steps run on temporary EC2 instances in your account, so they can reach a private RDS instance without exposing it — with the same database access requirements as option 1. Reference the database URL from Secrets Manager rather than pasting it into the pipeline file — see Managing secrets. Like the pre-deploy hook, this adds runner startup time to every deploy; its advantage is that the migration is visible as its own step and shared by every deploy after it.

Where not to run migrations

Dockerfile image build instructions on their own. Running db:migrate in an arbitrary RUN without the setup in option 1 fails for lack of credentials or network access, and leaking the connection string into an ENV bakes it into the image. Procfile release phase. Ravion does not run Heroku-style release processes. If you are migrating from Heroku, move the command from release: to the end of the build or to a pre-deploy hook. Application start. Running migrations in the container entrypoint runs them on every task — including autoscaling events and task replacements — and usually in parallel. It also means a broken migration takes the whole service down rather than failing the deploy.

Rollbacks

A rollback redeploys a previous image; it does not reverse the schema. Write migrations that are backward compatible with the previous release — add columns before reading them, drop columns one release after you stop writing them — so a rollback is always safe.