# 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`](https://github.com/devtide-llc/cloud-pilot/blob/main/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](./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:

| 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](./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](./github-connections)
  for the required App Installation choice and troubleshooting steps.
