Appearance
Deploying a Rails app with Keel
A walkthrough for the shape most Rails apps take: one image, a Puma web process, a Sidekiq worker, and a migration that must run before either serves traffic.
The config in this guide is internal/config/testdata/rails-web-worker.yml, which the test suite loads, validates, and generates Terraform from on every run — so it cannot drift from what Keel actually accepts.
The config
yaml
name: acme
region: us-west-2
environment: # merged into every service
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: "true"
secrets: # added to every service
- SECRET_KEY_BASE
services:
web:
type: web
port: 3000
health_check:
path: /up
grace_period: 90
cpu: 512
memory: 1024
command: bundle exec thrust ./bin/rails server
sidekiq:
type: worker
cpu: 512
memory: 1024
command: ["bundle", "exec", "sidekiq"]
release:
command: bundle exec rails db:migrate
service: web
timeout: 20m
database:
engine: postgres
version: "16"
instance: db.t4g.small
storage: 50
multi_az: true
cache:
engine: valkey
node_type: cache.t4g.micro
pipeline:
source: github
repo: acme-corp/acme
branch: mainThe four things worth understanding
1. One image, two processes
command overrides the image's CMD. Both services are built from the same Dockerfile and the same commit; only the command differs.
Without command, both would run the image's default CMD — so a type: worker service would silently boot a second web server. Nothing errors. The visible symptom is that no background job ever runs, with no log line anywhere saying so.
command is argv, not a shell line. The scalar form (command: bundle exec sidekiq) is split on whitespace, but shell syntax is rejected — the container command runs as PID 1, and a shell there does not forward SIGTERM, so ECS would hard-kill every task on every deploy. When you genuinely need a shell, ask for one: ["sh", "-c", "first && second"].
Because both services share a Dockerfile, they also share one CodeBuild project: the image is built once and tagged for each service, so web and worker can never end up on different builds of the same commit.
2. Migrations run before traffic
release: runs once per deploy, on the newly registered task definition, before either service is pointed at it. A non-zero exit fails the deploy and leaves both services on their previous revision.
keel deploy is phased:
build → register a revision per service → run release once → promote eachThis is why migrating on container boot is not necessary: every task would race the migration lock, and a failed migration would leave a half-migrated app serving traffic rather than failing the deploy.
The release runs with release.service's task role and security group. Name the service that can reach the database — for a typical Rails app either will do, but if only one of them has database access, say which.
A migrator credential goes on the release, not on a service. If your runtime role is NOSUPERUSER NOBYPASSRLS so that row-level security actually applies — the normal arrangement for a multi-tenant app — the migration needs a different, more privileged role. Declare it under release: and it is present on the release task and on nothing else:
yaml
release:
command: bundle exec rails db:migrate
service: web
secrets:
- MIGRATOR_DATABASE_URLThat moves the release onto its own task-definition family, {app}-{env}-release, which exists only for the length of one migration. Declared on the web service instead, the credential would sit in every internet-facing task for the life of the deployment in order to be read once a deploy. Database credentials has the role-creation half.
For anything ad hoc:
bash
keel run -- bundle exec rails runner 'Backfill.new.call'
keel run -i -- bundle exec rails consolekeel run starts a new task rather than attaching to a running one, which is what makes it useful when nothing is healthy — often the case when a migration is the thing that would make it healthy.
3. Connecting to Postgres and Valkey
Keel injects the parts rather than a single URL, because the password lives in an AWS-managed secret that rotates:
| Variable | Notes |
|---|---|
DATABASE_HOST, DATABASE_PORT, DATABASE_NAME | |
DATABASE_USER, DATABASE_PASSWORD | injected from the managed secret |
DATABASE_SCHEME | postgres here |
CACHE_URL, REDIS_URL | a complete rediss:// URL |
yaml
# config/database.yml
production:
adapter: postgresql
host: <%= ENV["DATABASE_HOST"] %>
port: <%= ENV["DATABASE_PORT"] %>
database: <%= ENV["DATABASE_NAME"] %>
username: <%= ENV["DATABASE_USER"] %>
password: <%= ENV["DATABASE_PASSWORD"] %>Take the parts, do not interpolate a URL. A password containing ?, # or % produces a string that parses into something other than what you wrote — usually an auth failure, occasionally a connection to a different database. RDS forbids /, ", @ and space in a master password, so the two characters that would break it most obviously cannot occur, which is exactly what makes the interpolated form look like it works. Where a framework takes only a URL, percent-encode each part; Database credentials has a worked entrypoint.
ruby
# config/initializers/sidekiq.rb
Sidekiq.configure_server { |c| c.redis = { url: ENV.fetch("REDIS_URL") } }
Sidekiq.configure_client { |c| c.redis = { url: ENV.fetch("REDIS_URL") } }Use REDIS_URL, not CACHE_HOST and CACHE_PORT. The cache endpoint has transit encryption enabled, so it speaks TLS only. A client that assembles redis://host:6379 from the host and port cannot connect, and the failure looks like a hang rather than a refusal.
database.name and database.username default to keeldb and keeluser and are configurable, so an existing app does not have to rename its database.
4. Boot time and health checks
health_check.grace_period is how long ECS waits before it is willing to replace a new task. It defaults to 60 seconds in Keel; a Rails app doing bootsnap and eager loading often wants more, hence 90 above.
This matters because ECS's own default is 0 — a task becomes eligible for replacement the moment it registers, so an app that takes 40 seconds to boot is killed before it serves its first request, and then loops.
Every service also gets a deployment circuit breaker with rollback, so a bad deploy stops and reverts rather than restarting forever.
Rails needs a health endpoint that does not touch the database — Rails 7.1+ mounts /up for exactly this.
First deploy
bash
keel auth login # prompts for an MFA code; run `keel auth setup` first if you have not
keel up # provisions VPC, ALB, ECS, RDS, ElastiCache, CodeBuild
keel config set SECRET_KEY_BASE=$(bin/rails secret)
keel deploykeel up creates the services before any image exists, so they cannot pull one and restart until the first keel deploy lands. That is expected, and keel up says so.
Set every name in secrets: with keel config set before the first deploy — a task whose secret is missing fails to start, and the reason surfaces in the deploy output as a ResourceInitializationError.
Environments
yaml
environments:
staging:
services:
web:
desired_count: 1
database:
instance: db.t4g.micro
storage: 20
multi_az: false
production:
services:
web:
desired_count: 3
default_environment: stagingOverrides merge field by field, so the staging database: block above keeps the base engine, version, and backup settings. Only name what differs.
bash
keel deploy --env production
keel logs web --env production
keel run --env production -- bundle exec rails db:migrate:statusTearing down
bash
keel destroy --env stagingThe prompt names the database, says a final snapshot is taken first, lists any retain: true resources that will be left running in AWS, and asks you to type the app name. Afterwards it prints the exact aws command to delete each thing that survived.
Things Keel does not do yet
- Keel creates no second database role. It provisions the RDS master and nothing else: creating a Postgres role means running SQL inside the VPC, and there is no point in an apply where that is a safe, idempotent thing to do on your behalf. Create the roles yourself — from a migration, or as the master user through
keel exec— put each credential in SSM withkeel config set, and scope them withsecrets:on the services andrelease.secretson the release. Keel does not rotate them either; they are ordinary SSM parameters. - One public web service can own the root route. Internal web services use service-specific hostnames and can share the internal load balancer.
- A private GitHub repo needs an AWS CodeConnections connection. Run
keel auth connect github {organization}, then complete the one-time GitHub App authorization in the browser. Follow Connecting GitHub for the required App Installation choice and troubleshooting steps.