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
mode: advanced
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.
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:
url: <%= "#{ENV['DATABASE_SCHEME']}://#{ENV['DATABASE_USER']}:#{ENV['DATABASE_PASSWORD']}@#{ENV['DATABASE_HOST']}:#{ENV['DATABASE_PORT']}/#{ENV['DATABASE_NAME']}" %>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
- No second database role. The app connects as the master user. If you use row-level security and need a non-superuser runtime role, create it yourself and put its URL in SSM with
keel config set. - One web service per load balancer. Path-based routing is not implemented, so a second
type: webservice is rejected at validation rather than provisioned with a target group that receives no traffic. - A private GitHub repo needs a CodeStar connection. Create it once in the console and set
pipeline.connection_arn; Keel cannot complete the OAuth handshake for you.