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.
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:
- Build Docker image locally (~1–2 min)
- Push to registry (~30 sec)
- Pull image on server (~30 sec)
- Start new container on new port (~10 sec)
- Health check passes (~10 sec)
- kamal-proxy switches traffic (instant)
- 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