Skip to content
Back to blog

Zero-Downtime Rails 8 Deployments on AWS with Kamal: A Complete Guide

A complete guide to deploying Rails 8 applications on AWS using Kamal. Learn how to achieve zero-downtime deployments while reducing costs from $75 to $27/month.

Deploy a Rails application to AWS using Kamal

If you've ever deployed a Next.js app to Vercel, you know the magic of "git push and you're live." But if you're a Rails developer? You're stuck configuring servers, managing databases, and wrestling with deployment pipelines.

I recently set out to simplify Rails deployment for a personal project of mine to simplify this process. Along the way, I learned why Kamal, the deployment tool built by 37signals (the creators of Rails) is the recommended approach (and that it's really annoyingly documented).

This is the complete story of that journey, including every pitfall I hit and how I solved them.

The Starting Point: Infrastructure

My initial approach was "do it the AWS way." I built a CloudFormation template with all the basic enterprise bells and whistles:

  • VPC with public and private subnets
  • NAT Gateway for outbound traffic from private subnets
  • Application Load Balancer
  • ECS Fargate for container orchestration
  • RDS PostgreSQL
  • Secrets Manager for credentials

The template worked. But the costs added up quickly:

  • NAT Gateway — $22
  • ALB — $16
  • Fargate — $15
  • RDS T3 Micro — $12

That's a total of ~$75/month. I'm just developing a prototype and like I said, if you've been in the Vercel world, I'd rather just write my project in TS and deploy there. I'd honestly even look at switching to Laravel Forge or using Supabase.

Enter Kamal

Given the release of Rails 8 last year and subsequent updates, with an emphasis on smoothing out deployments, Kamal 2 was a big mention.

The pitch is simple: you get zero-downtime deployments without the complexity of Kubernetes or ECS.

This brings my self managed infrastructure down to:

  • EC2 t3.small — $15
  • RDS T3 Micro — $12

Down from $75 to $27/month. That's a 64% cost reduction, and the deployment process is dramatically simpler. I still also control the core infrastructure.

How Zero-Downtime Works with Kamal

The magic is in kamal-proxy, a lightweight reverse proxy that runs on your server.

Here's the deployment sequence:

  1. Build Docker image locally (~1–2 min)
  2. Push to registry (~30 sec)
  3. Pull image on server (~30 sec)
  4. Start new container on new port (~10 sec)
  5. Health check passes (~10 sec)
  6. kamal-proxy switches traffic (instant)
  7. Stop old container (~10 sec)

During step 6, kamal-proxy atomically switches traffic from the old container to the new one. No dropped requests. No load balancer reconfiguration.

Compare this to a traditional blue-green deployment where you're spinning up new EC2 instances, updating target groups, and draining connections — easily a 20 minute process.

The Setup: Step by Step

Prerequisites

  • AWS account
  • Rails application with a Dockerfile (Rails 7.1+ generates one)
  • Domain name (optional — you can test with IP address)

Step 1: CloudFormation Template

Let's create a basic re-usable cloud formation template. My CloudFormation template provisions just the essentials:

  • VPC with public subnet
  • EC2 instance for your application
  • RDS PostgreSQL database
  • Secrets Manager generates and stores the database credentials automatically.

The whole stack deploys in about 10 minutes.

AWSTemplateFormatVersion: '2010-09-09'
Description: Rails Infra - EC2 + RDS for Kamal deployment

Parameters:
  KeyPairName:
    Type: AWS::EC2::KeyPair::KeyName
    Description: SSH key pair for EC2 access
  DBName:
    Type: String
    Default: rails_app_production
  InstanceType:
    Type: String
    Default: t3.small
    AllowedValues: [t3.micro, t3.small, t3.medium]
  YourIP:
    Type: String
    Description: Your IP for SSH access (e.g., 203.0.113.0/32)

Resources:
  #### VPC + Networking ####
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: rails-app-vpc

  InternetGateway:
    Type: AWS::EC2::InternetGateway

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.0.0/24
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: rails-app-public-1

  # ... (truncated for brevity - see full template in repo)

And deploy it:

aws cloudformation deploy \
  --stack-name myapp-infra \
  --template-file infrastructure.yml \
  --parameter-overrides \
    KeyPairName=myapp \
    YourIP=$(curl -s https://checkip.amazonaws.com)/32 \
  --capabilities CAPABILITY_NAMED_IAM

I've written a handy deploy script you can also use that will make this a little easier if you don't care about the process and just want to get up and running. You can find the code here: github.com/GoodPie/kamal-application-iaac

Step 2: Create an ECR Repository

We need to be able to push our built images somewhere. We are already in the AWS space, so ECR suits my needs but you could alternatively deploy to Docker or Github for free.

aws ecr create-repository --repository-name myapp --region ap-southeast-2

Step 3: Initialize and configure Kamal

You can and should read the documentation for Kamal before proceeding or at least have it in the background. I found it a little bit lacking, especially for Rails.

# Install the gem
gem install kamal

# Initialize Kamal
kamal init

From these commands, you should have a file config/deploy.yml in your project. Edit this and add your credentials from your CloudFormation output:

service: myapp
image: myapp

servers:
  web:
    hosts:
      - 52.62.xxx.xxx # Your EC2 IP from CloudFormation output

proxy:
  ssl: false # Start without SSL for testing
  host: 52.62.xxx.xxx
  healthcheck:
    path: /up

registry:
  server: 123456789.dkr.ecr.ap-southeast-2.amazonaws.com
  username: AWS
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

env:
  clear:
    RAILS_ENV: production
    RAILS_LOG_TO_STDOUT: 'true'
    RAILS_SERVE_STATIC_FILES: 'true'
    DATABASE_HOST: myapp-db.xxx.ap-southeast-2.rds.amazonaws.com
    DATABASE_NAME: myapp_production
    DATABASE_USER: myapp
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_PASSWORD

ssh:
  user: ubuntu
  keys:
    - ~/.ssh/myapp.pem

Step 4: Create our Secrets

Let's create or edit our .kamal/secrets file:

KAMAL_REGISTRY_PASSWORD=$(aws ecr get-login-password --region ap-southeast-2)
RAILS_MASTER_KEY=$(cat config/master.key)
DATABASE_PASSWORD=$(aws secretsmanager get-secret-value --secret-id myapp/db-credentials --query 'SecretString' --output text --region ap-southeast-2 | jq -r '.password')

Step 5: Deploy

Let's finally deploy:

# First time - installs Docker, kamal-proxy
kamal setup

# Subsequent deploys
kamal deploy

Pitfalls I Hit (So You Don't Have To)

1. Docker Desktop's Credential Store

Symptom: docker login works manually but fails through Kamal with "400 Bad Request"

Cause: Docker Desktop on macOS uses a credential store that interferes with Kamal's login.

Fix: Remove credsStore from ~/.docker/config.json:

        
{
"auths"%colon; {	},
"currentContext"%colon; "desktop-linux"
}
        
    

2. ECR Image Path Doubling

Symptom: Error mentions ecr.amazonaws.com/ecr.amazonaws.com/myapp

Cause: Putting the full ECR URL in both image and registry.server.

Fix: Use just the repository name in image:

image: myapp # Not the full ECR URL
registry:
  server: 123456789.dkr.ecr.ap-southeast-2.amazonaws.com

3. Docker Permission Denied on Server

Symptom: permission denied while trying to connect to the Docker API

Cause: The ubuntu user isn't in the docker group.

ssh -i ~/.ssh/myapp.pem ubuntu@your-server-ip
sudo usermod -aG docker ubuntu
exit

Then retry kamal setup.

4. Database Using Socket Instead of TCP

Symptom: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed

Cause: Your database.yml isn't reading the environment variables.

Fix: Ensure your production config uses the env vars:

database: <%= ENV['DATABASE_NAME'] %>
username: <%= ENV['DATABASE_USER'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>

Production Considerations

Thruster: The Missing Piece in Rails 8

Rails 8 introduces Thruster, a lightweight HTTP/2 proxy that wraps Puma inside your container. It's easy to confuse with kamal-proxy, so let's clarify:

  • Kamal Proxy — Sits on the host and routes traffic between containers with zero-downtime switching
  • Thruster — HTTP/2, asset caching, compression and TLS
  • Puma — Ruby application server

The request flow looks like:

Internet → kamal-proxy (host:80/443) → Thruster (container:3000) → Puma (container:3001)

If you're on Rails 7.x, add Thruster to your Gemfile:

gem 'thruster'

Then update your Dockerfile's CMD:

CMD ["thrust", "bin/rails", "server", "-b", "0.0.0.0"]

Health Checks

The kamal-proxy needs to verify your app is healthy before routing traffic. Rails 7.1+ provides /up out of the box. Configure appropriate timeouts:

proxy:
  healthcheck:
    path: /up
    interval: 3
    timeout: 3

SSL

For production, enable SSL:

proxy:
  ssl: true
  host: myapp.com

Kamal uses Let's Encrypt automatically.

Conclusion

Kamal won't replace Kubernetes for complex microservice architectures. But for most Rails applications — especially those run by small teams — it's a breath of fresh air.

The deployment experience is closer to what frontend developers have with Vercel:

git push origin main
kamal deploy

Two commands. Two minutes. Zero downtime.

The Rails community has been waiting for this. With Rails 8's deployment-focused defaults, Thruster for HTTP/2 and asset caching, Solid Queue/Cache/Cable eliminating Redis dependencies, and Kamal for orchestration, the "just deploy it" experience is finally within reach.

The full Rails 8 production stack:

kamal-proxy (traffic routing, zero-downtime)
       ↓
Thruster (HTTP/2, caching, compression)
       ↓
Puma (application server)
       ↓
Solid Queue/Cache/Cable (database-backed background jobs, caching, websockets)

No Nginx. No Redis. No Kubernetes. Just Rails.

Let me know if I missed anything here.

Remember, you can find the IaaC here: github.com/GoodPie/kamal-application-iaac