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

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.

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_URL

That 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 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:
  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 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 ​

  • 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 with keel config set, and scope them with secrets: on the services and release.secrets on 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.

Last updated:

Keel — the missing platform layer for AWS.