Tools: Deploying Rails AI Apps with Kamal on a VPS — Docker, SSH, Zero Downtime - Analysis

Tools: Deploying Rails AI Apps with Kamal on a VPS — Docker, SSH, Zero Downtime - Analysis

What Is Kamal?

Prerequisites

Install Kamal

Prepare Your Rails App

Initialize Kamal

Set Your Secrets

Bootstrap the Server

Deploy Updates

Useful Kamal Commands

Running Migrations on Deploy

Health Checks

Environment Variables for AI

What You Have Now We've spent 27 posts writing code. Now it's time to put it on a server. Not a managed platform. Not a serverless function. A real server that you control, running Docker containers deployed with Kamal — Rails' official deployment tool. This is post #29 in the Ruby for AI series. Let's deploy. Kamal (formerly MRSK) is a deployment tool from the Rails team. It uses Docker to package your app and SSH to deploy it to any server. No proprietary runtime. No vendor lock-in. Just Docker + SSH + a VPS. Think of it as Capistrano for the container age. If your app doesn't have a Dockerfile yet, Rails 7.1+ generates one by default. If you're on an older version: This creates a production-ready, multi-stage Dockerfile. Check it looks something like this: This creates config/deploy.yml — the heart of your deployment config. Edit it: Create .kamal/secrets (this file is gitignored): First deployment — this installs Docker on your VPS and sets everything up: After the first setup, subsequent deploys are one command: This builds your Docker image, pushes it, and performs a zero-downtime rolling deploy. The old container keeps serving requests until the new one is healthy. Add a pre-deploy hook. Create .kamal/hooks/pre-deploy: Kamal checks your app's health before routing traffic. It hits /up by default (Rails 7.1+ includes this route). Make sure yours works: If the health check fails, Kamal won't route traffic to the new container. The old one keeps running. No downtime from bad deploys. Your AI app needs API keys. Keep them in .kamal/secrets and reference them in deploy.yml under env.secret. They're injected at runtime as environment variables — never baked into the Docker image. No vendor lock-in. No surprise bills. No "free tier" that becomes $500/month. Just your code, on your server. Next up: Post #30 goes deeper into VPS setup — Nginx, Puma tuning, systemd, and SSL the manual way. For when you want full control over every layer. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

gem install kamal gem install kamal gem install kamal kamal version kamal version kamal version rails generate dockerfile rails generate dockerfile rails generate dockerfile # syntax=docker/dockerfile:1 FROM ruby:3.3-slim AS base WORKDIR /rails ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" FROM base AS build RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential git libpq-dev COPY Gemfile Gemfile.lock ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache COPY . . RUN bundle exec rails assets:precompile FROM base RUN apt-get update -qq && \ apt-get install --no-install-recommends -y libpq5 curl && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives COPY --from=build /usr/local/bundle /usr/local/bundle COPY --from=build /rails /rails RUN groupadd --system --gid 1000 rails && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ chown -R rails:rails db log storage tmp USER 1000:1000 ENTRYPOINT ["/rails/bin/docker-entrypoint"] EXPOSE 3000 CMD ["./bin/rails", "server"] # syntax=docker/dockerfile:1 FROM ruby:3.3-slim AS base WORKDIR /rails ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" FROM base AS build RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential git libpq-dev COPY Gemfile Gemfile.lock ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache COPY . . RUN bundle exec rails assets:precompile FROM base RUN apt-get update -qq && \ apt-get install --no-install-recommends -y libpq5 curl && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives COPY --from=build /usr/local/bundle /usr/local/bundle COPY --from=build /rails /rails RUN groupadd --system --gid 1000 rails && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ chown -R rails:rails db log storage tmp USER 1000:1000 ENTRYPOINT ["/rails/bin/docker-entrypoint"] EXPOSE 3000 CMD ["./bin/rails", "server"] # syntax=docker/dockerfile:1 FROM ruby:3.3-slim AS base WORKDIR /rails ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" FROM base AS build RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential git libpq-dev COPY Gemfile Gemfile.lock ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache COPY . . RUN bundle exec rails assets:precompile FROM base RUN apt-get update -qq && \ apt-get install --no-install-recommends -y libpq5 curl && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives COPY --from=build /usr/local/bundle /usr/local/bundle COPY --from=build /rails /rails RUN groupadd --system --gid 1000 rails && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ chown -R rails:rails db log storage tmp USER 1000:1000 ENTRYPOINT ["/rails/bin/docker-entrypoint"] EXPOSE 3000 CMD ["./bin/rails", "server"] service: myaiapp image: yourdockerhub/myaiapp servers: web: hosts: - 203.0.113.10 labels: traefik.http.routers.myaiapp.rule: Host(`myaiapp.com`) traefik.http.routers.myaiapp.tls.certresolver: letsencrypt proxy: ssl: true host: myaiapp.com registry: username: yourdockerhub password: - KAMAL_REGISTRY_PASSWORD env: clear: RAILS_ENV: production RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "true" secret: - RAILS_MASTER_KEY - DATABASE_URL - REDIS_URL - OPENAI_API_KEY accessories: db: image: postgres:16 host: 203.0.113.10 port: "127.0.0.1:5432:5432" env: clear: POSTGRES_DB: myaiapp_production secret: - POSTGRES_PASSWORD directories: - data:/var/lib/postgresql/data redis: image: redis:7 host: 203.0.113.10 port: "127.0.0.1:6379:6379" directories: - data:/data service: myaiapp image: yourdockerhub/myaiapp servers: web: hosts: - 203.0.113.10 labels: traefik.http.routers.myaiapp.rule: Host(`myaiapp.com`) traefik.http.routers.myaiapp.tls.certresolver: letsencrypt proxy: ssl: true host: myaiapp.com registry: username: yourdockerhub password: - KAMAL_REGISTRY_PASSWORD env: clear: RAILS_ENV: production RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "true" secret: - RAILS_MASTER_KEY - DATABASE_URL - REDIS_URL - OPENAI_API_KEY accessories: db: image: postgres:16 host: 203.0.113.10 port: "127.0.0.1:5432:5432" env: clear: POSTGRES_DB: myaiapp_production secret: - POSTGRES_PASSWORD directories: - data:/var/lib/postgresql/data redis: image: redis:7 host: 203.0.113.10 port: "127.0.0.1:6379:6379" directories: - data:/data service: myaiapp image: yourdockerhub/myaiapp servers: web: hosts: - 203.0.113.10 labels: traefik.http.routers.myaiapp.rule: Host(`myaiapp.com`) traefik.http.routers.myaiapp.tls.certresolver: letsencrypt proxy: ssl: true host: myaiapp.com registry: username: yourdockerhub password: - KAMAL_REGISTRY_PASSWORD env: clear: RAILS_ENV: production RAILS_LOG_TO_STDOUT: "1" RAILS_SERVE_STATIC_FILES: "true" secret: - RAILS_MASTER_KEY - DATABASE_URL - REDIS_URL - OPENAI_API_KEY accessories: db: image: postgres:16 host: 203.0.113.10 port: "127.0.0.1:5432:5432" env: clear: POSTGRES_DB: myaiapp_production secret: - POSTGRES_PASSWORD directories: - data:/var/lib/postgresql/data redis: image: redis:7 host: 203.0.113.10 port: "127.0.0.1:6379:6379" directories: - data:/data KAMAL_REGISTRY_PASSWORD=your_docker_hub_token RAILS_MASTER_KEY=your_master_key_from_config/master.key DATABASE_URL=postgresql://postgres:yourpassword@myaiapp-db:5432/myaiapp_production REDIS_URL=redis://myaiapp-redis:6379/0 OPENAI_API_KEY=sk-your-openai-key POSTGRES_PASSWORD=yourpassword KAMAL_REGISTRY_PASSWORD=your_docker_hub_token RAILS_MASTER_KEY=your_master_key_from_config/master.key DATABASE_URL=postgresql://postgres:yourpassword@myaiapp-db:5432/myaiapp_production REDIS_URL=redis://myaiapp-redis:6379/0 OPENAI_API_KEY=sk-your-openai-key POSTGRES_PASSWORD=yourpassword KAMAL_REGISTRY_PASSWORD=your_docker_hub_token RAILS_MASTER_KEY=your_master_key_from_config/master.key DATABASE_URL=postgresql://postgres:yourpassword@myaiapp-db:5432/myaiapp_production REDIS_URL=redis://myaiapp-redis:6379/0 OPENAI_API_KEY=sk-your-openai-key POSTGRES_PASSWORD=yourpassword kamal setup kamal setup kamal setup kamal deploy kamal deploy kamal deploy # Check what's running kamal details # View logs kamal app logs # Open a Rails console on the server kamal app exec -i "bin/rails console" # Run database migrations kamal app exec "bin/rails db:migrate" # Rollback to previous version kamal rollback # Start/stop accessories kamal accessory start db kamal accessory stop redis # Check what's running kamal details # View logs kamal app logs # Open a Rails console on the server kamal app exec -i "bin/rails console" # Run database migrations kamal app exec "bin/rails db:migrate" # Rollback to previous version kamal rollback # Start/stop accessories kamal accessory start db kamal accessory stop redis # Check what's running kamal details # View logs kamal app logs # Open a Rails console on the server kamal app exec -i "bin/rails console" # Run database migrations kamal app exec "bin/rails db:migrate" # Rollback to previous version kamal rollback # Start/stop accessories kamal accessory start db kamal accessory stop redis #!/bin/bash echo "Running database migrations..." kamal app exec "bin/rails db:migrate" #!/bin/bash echo "Running database migrations..." kamal app exec "bin/rails db:migrate" #!/bin/bash echo "Running database migrations..." kamal app exec "bin/rails db:migrate" chmod +x .kamal/hooks/pre-deploy chmod +x .kamal/hooks/pre-deploy chmod +x .kamal/hooks/pre-deploy # config/routes.rb get "up" => "rails/health#show", as: :rails_health_check # config/routes.rb get "up" => "rails/health#show", as: :rails_health_check # config/routes.rb get "up" => "rails/health#show", as: :rails_health_check # In your Rails app, access them normally: OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) # In your Rails app, access them normally: OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) # In your Rails app, access them normally: OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]) - A VPS (Ubuntu 22.04+ or Debian 12+) with SSH access - Docker installed locally - Ruby 3.2+ locally - A Docker Hub account (or any container registry) - SSHs into your server - Installs Docker if needed - Pushes your image to the registry - Starts your accessories (Postgres, Redis) - Runs your app container - Configures Kamal's built-in proxy (kamal-proxy) with automatic SSL - Your Rails AI app runs in a Docker container on a VPS you control - Kamal handles zero-downtime deploys via SSH - Postgres and Redis run as Docker accessories on the same server - SSL is handled automatically via kamal-proxy and Let's Encrypt - Secrets are injected at runtime, never in the image - Health checks prevent bad deploys from taking down your app