Skip to content

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: main

The 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 each

This 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 console

keel 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:

VariableNotes
DATABASE_HOST, DATABASE_PORT, DATABASE_NAME
DATABASE_USER, DATABASE_PASSWORDinjected from the managed secret
DATABASE_SCHEMEpostgres here
CACHE_URL, REDIS_URLa 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 deploy

keel 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: staging

Overrides 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:status

Tearing down

bash
keel destroy --env staging

The 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: web service 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.

Last updated:

Keel — the AWS CLI you've always wanted.