Learning to Deploy with Kamal


Rails Kamal Docker React TanStack
deployment devops infrastructure

Learning to Deploy with Kamal

Coming from Heroku’s managed platform where a few heroku CLI commands abstract away server provisioning, environment configuration, and database setup, I decided to adopt Kamal v2 (Rails’ new official deployment tool) for my Rails 8 project to leverage Docker-based deployments on my own DigitalOcean infrastructure.

I thought I knew what I was doing. After all, I’ve deployed apps before, right?

Well… let me tell you about my adventure.

Deployment Successful

Testing if the database is working - let’s create a user:

Testing Database Connection

What I Was Building

Note: This is Rails with React integrated as a single application – not a separate frontend/backend setup. Everything’s bundled together using Vite.

Backend:

  • Rails 8.1.0 (beta) with Ruby 3.4.5
  • PostgreSQL (DigitalOcean Managed Database)

Frontend:

  • React 18 with TypeScript
  • TanStack Router (file-based routing)
  • TanStack Query (data fetching)
  • Vite (build tool)
  • DaisyUI + TailwindCSS (styling)

Deployment:

  • Kamal v2 (deployment tool)
  • Docker (containerization)
  • DigitalOcean Droplet (server)
  • DigitalOcean Container Registry
  • DigitalOcean Managed PostgreSQL
  • Cloudflare (DNS + SSL/CDN)
  • GitHub Actions (CI/CD)

Infrastructure Setup (DigitalOcean)

All infrastructure is hosted on DigitalOcean for simplicity:

  1. Droplet: Ubuntu server running Docker
  2. Managed Database: PostgreSQL database (no manual DB management)
  3. Container Registry: Private Docker image registry

This setup provides fully managed, scalable infrastructure with minimal operational overhead.

Key Configuration Files

1. Kamal Deploy Configuration

The most important file for Kamal deployment:

# config/deploy.yml
service: your-app-name
image: your-registry/your-app-name

servers:
  web:
    - YOUR_SERVER_IP

# Let Kamal handle SSL with Let's Encrypt
proxy:
  ssl: true
  host: your-domain.com
  app_port: 3000

registry:
  server: registry.digitalocean.com
  username:
    - KAMAL_REGISTRY_PASSWORD
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
    - DEVISE_JWT_SECRET_KEY
  clear:
    RAILS_ENV: production
    RAILS_SERVE_STATIC_FILES: true
    RAILS_LOG_TO_STDOUT: true

2. Database Configuration

Use a single database connection:

# config/database.yml
production:
  <<: *default
  url: <%= ENV["DATABASE_URL"] %>

3. Production Environment Tweaks

# config/environments/production.rb

# Exclude health check endpoint from SSL redirects
config.ssl_options = { 
  redirect: { exclude: ->(request) { request.path == "/up" } } 
}

# Exclude health check from host authorization
config.host_authorization = { 
  exclude: ->(request) { request.path == "/up" } 
}

4. Dockerfile Updates

Two critical changes to the Dockerfile:

# Set production environment for both Rails and Node
ENV RAILS_ENV="production" \
    NODE_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Expose port 3000 and run Rails server directly (not Thruster)
EXPOSE 3000
CMD ["./bin/rails", "server", "-b", "0.0.0.0", "-p", "3000"]

5. Hide Devtools in Production

Ensure TanStack devtools only show in development:

// app/frontend/routes/__root.tsx
{import.meta.env.DEV && <TanStackRouterDevtools position="bottom-right" />}

// app/frontend/entrypoints/main.tsx
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}

Add Vite environment types:

// app/frontend/vite-env.d.ts
/// <reference types="vite/client" />

6. GitHub Actions Deployment

Automate deployments on every push to main:

# .github/workflows/deploy.yml
name: Deploy with Kamal

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan -H YOUR_SERVER_IP >> ~/.ssh/known_hosts

      - name: Create Kamal secrets file
        env:
          KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          DEVISE_JWT_SECRET_KEY: ${{ secrets.DEVISE_JWT_SECRET_KEY }}
        run: |
          mkdir -p .kamal
          cat > .kamal/secrets << EOF
          KAMAL_REGISTRY_PASSWORD=${KAMAL_REGISTRY_PASSWORD}
          RAILS_MASTER_KEY=${RAILS_MASTER_KEY}
          DATABASE_URL=${DATABASE_URL}
          DEVISE_JWT_SECRET_KEY=${DEVISE_JWT_SECRET_KEY}
          EOF
          chmod 600 .kamal/secrets

      - name: Deploy with Kamal
        run: |
          gem install kamal
          kamal deploy

GitHub Actions Deployment

Common Deployment Issues & Solutions

Issue 1: Health Check Timeouts

Problem: Container exits with status 1, health check fails after 30 seconds.

Solution: The /up endpoint was getting redirected by SSL and blocked by host authorization. Exclude it from both:

config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }

Issue 2: Database Configuration Errors

Problem: App crashes on startup with database connection errors.

Solution: Two things I had to fix:

  1. Make sure your DATABASE_URL is correctly set in GitHub Secrets and that your production database configuration uses it properly.
  2. Important: Add your application to the trusted sources in your DigitalOcean Managed Database settings (not just the Droplet IP). This is more secure and allows your Docker containers to connect properly.

Issue 3: SSL Certificate Errors

Problem: NET::ERR_CERTIFICATE_TRANSPARENCY_REQUIRED errors when accessing via HTTPS.

Solution: Initially disabled SSL in Kamal, but this caused issues with Cloudflare. The correct solution: enable ssl: true in Kamal to use Let’s Encrypt, and set Cloudflare SSL mode to “Full”.

Issue 4: Port Mismatch

Problem: Kamal proxy shows connection refused errors.

Solution: The Dockerfile was exposing port 80 and using Thruster, but Kamal expected port 3000. Changed to expose port 3000 and run Rails server directly.

Issue 5: Devtools in Production

Problem: TanStack Router and React Query devtools visible on production site.

Solution: Wrap devtools with import.meta.env.DEV checks and ensure NODE_ENV=production is set in the Dockerfile.

Issue 6: RAILS_MASTER_KEY Length

Problem: key must be 16 bytes (ArgumentError) error.

Solution: The Rails master key must be exactly 32 bytes (32 characters). Verify with:

wc -c config/master.key
# Should output: 32 config/master.key

GitHub Secrets Required

Add these secrets to your GitHub repository (Settings → Secrets and variables → Actions):

  • SSH_PRIVATE_KEY - SSH key for server access
  • KAMAL_REGISTRY_PASSWORD - DigitalOcean API token
  • RAILS_MASTER_KEY - 32-byte Rails encryption key from config/master.key
  • DATABASE_URL - PostgreSQL connection string from DO Managed Database
  • DEVISE_JWT_SECRET_KEY - JWT secret for authentication

Deployment Flow

  1. Push to main branch → Triggers GitHub Actions
  2. Build Docker image → Vite builds frontend assets, Rails precompiles
  3. Push to DO Registry → Image stored in private registry
  4. Deploy with Kamal → Pulls image, starts container, updates proxy
  5. Health check → Kamal verifies /up endpoint responds
  6. SSL provisioning → Let’s Encrypt certificate auto-generated
  7. Live! → App accessible via HTTPS

Boilerplate: TanStack Rails React DaisyUI

This setup is based on my tanstack-rails-react-daisyui boilerplate, which provides:

  • File-based routing with TanStack Router
  • Type-safe API calls with TanStack Query
  • Beautiful UI components with DaisyUI
  • Hot module replacement with Vite
  • Zero-config deployment with Kamal v2
  • Managed infrastructure on DigitalOcean

Perfect for building production-ready SaaS applications with Rails 8!

Final Architecture

User → Cloudflare (SSL/CDN)

       Kamal Proxy (Let's Encrypt SSL)

       Rails 8 App (Docker Container)

       DO Managed PostgreSQL

Lessons Learned

  1. Health checks matter: Exclude them from SSL redirects and host authorization
  2. Port consistency: Make sure Dockerfile, Kamal config, and app all use the same port
  3. Environment variables: Set both RAILS_ENV and NODE_ENV to “production”
  4. SSL with Cloudflare: Use Kamal’s SSL + Cloudflare “Full” mode for best results
  5. Devtools: Always wrap them with environment checks
  6. Master key: Must be exactly 32 bytes

Conclusion

Deploying Rails 8 with Kamal v2 is powerful once you understand the gotchas. The combination of Rails 8’s modern features, Kamal’s simplicity, and DigitalOcean’s managed services creates a robust, production-ready stack.

The key is understanding how all the pieces fit together: Docker builds, environment variables, health checks, SSL certificates, and proxy routing. Once you get past the initial hurdles, deployments become smooth and reliable.

Result: A fully deployed, production-ready Rails 8 application with modern React frontend! 🚀


Want to start with a ready-to-go setup? Check out my tanstack-rails-react-daisyui boilerplate - a modern Rails 8 + React starter template powered by the TanStack ecosystem (Router + Query) and styled with DaisyUI. Perfect for building production-ready SaaS applications!