← Blog

Deploy a Rails app to DigitalOcean

Railyard

April 14, 2026 · 10 min read

Follow this on your own server. Start free, no card needed

You have a Rails app working on your laptop and you need it running on a real server, with a real domain, a real database, and HTTPS that renews itself. Most guides either stop at “docker build” and leave you to figure out the rest, or they hand you a hosted platform with a credit card form. This one takes you all the way from an empty DigitalOcean account to a Rails app in production: a droplet, Docker Compose running your app and Postgres, Caddy terminating HTTPS, a backup job, and a repeatable way to ship your next release.

What you’ll need

  • A DigitalOcean account and a Basic Droplet sized s-1vcpu-2gb (1 vCPU, 2 GB memory, 50 GB SSD), currently $12.00/month.
  • A domain name you can add a DNS record to.
  • An SSH key pair on your machine (ssh-keygen -t ed25519 if you don’t have one).
  • A Rails app, or a new one: this guide builds one from scratch with rails new, so you can follow along even if you don’t have an app yet.
  • Fifteen minutes for the droplet and DNS to settle, mostly spent waiting rather than typing.

Step 1: Create the droplet

In the DigitalOcean control panel, choose Create, then Droplets. Pick Ubuntu 24.04 (LTS) x64 as the image, Basic as the plan, Regular SSD, and the s-1vcpu-2gb size ($12.00/month, 2 GB memory, 1 vCPU, 50 GB SSD). Pick a region close to your users, Frankfurt or Amsterdam if you need to keep data in the EU. Under Authentication, choose SSH key and add the public key from your machine rather than a password, then create the droplet.

Once it boots, DigitalOcean shows you its public IP. Connect to confirm access:

ssh root@YOUR_DROPLET_IP

You should see the Ubuntu welcome banner and land at a root shell. If the connection is refused, double check the SSH key you added matches the private key on your machine.

Step 2: Point your domain at the droplet

Add an A record at your DNS provider for the hostname you want to use, pointing at the droplet’s IP:

Type: A
Name: myapp (or @ for the bare domain)
Value: YOUR_DROPLET_IP
TTL: 300

DNS propagation can take a few minutes. Check it from your machine before moving on, since Caddy will need this to already resolve correctly in a later step:

dig +short myapp.example.com

You should see your droplet’s IP printed back. If you get nothing, wait a few minutes and try again before continuing.

Step 3: Install Docker on the droplet

Rails’s generated Dockerfile needs Docker Engine and the Compose plugin on the server. Docker’s own install script handles both on a fresh Ubuntu box:

apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker

Also open the firewall for SSH, HTTP and HTTPS, and nothing else:

ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable

Check the install:

docker --version
docker compose version

You should see a Docker version and a Compose version printed, not a “command not found” error.

Step 4: Generate the Rails app with a Dockerfile and Postgres

On your own machine, generate a new app configured for Postgres instead of the SQLite default:

gem install rails -v 8.1.4
rails new myapp --database=postgresql
cd myapp

rails new creates a Dockerfile by default (it’s one of the files listed when the generator runs, alongside .dockerignore), tuned for production: it builds your gems and assets in one stage, then copies only what’s needed into a slim runtime image. Rails 8 also includes Thruster, a small HTTP proxy in front of Puma that handles HTTP/2, compression and X-Sendfile, which is why the generated image exposes port 80 internally and starts with ./bin/thrust ./bin/rails server rather than starting Puma directly. You won’t hit that port from the internet directly, Caddy will sit in front of it.

Because you passed --database=postgresql, config/database.yml is set up for the pg gem instead of SQLite, and your Gemfile.lock now lists pg, which is exactly what tells a host that this app wants a Postgres database rather than MySQL or SQLite.

Verify the files are there:

ls Dockerfile .dockerignore config/database.yml

You should see all three paths printed with no error.

Step 5: Configure the database connection and secrets

Open config/database.yml and point the production section at a single connection string instead of separate host/username/password keys, since that’s what you’ll inject from Docker Compose:

production:
  <<: *default
  url: <%= ENV.fetch("DATABASE_URL") %>

Rails encrypts config/credentials.yml.enc with config/master.key, a file that’s gitignored on purpose. You won’t ship that file to the server. Instead, read its value once:

cat config/master.key

On the droplet, create a directory for the app and an .env file with that key and a database password you generate yourself:

mkdir -p /opt/myapp
cat > /opt/myapp/.env <<'EOF'
RAILS_MASTER_KEY=paste-the-value-from-config-master-key-here
POSTGRES_PASSWORD=a-long-random-password
EOF
chmod 600 /opt/myapp/.env

Check it:

cat /opt/myapp/.env

You should see both variables with real values, not placeholders, and the file should not be world-readable (-rw------- from ls -l).

Step 6: Write the Docker Compose file

Copy your app to the droplet (scp, rsync, or a git clone of your repository into /opt/myapp), then add compose.yml next to it:

services:
  app:
    build: .
    image: myapp:latest
    restart: unless-stopped
    env_file: .env
    environment:
      RAILS_ENV: production
      DATABASE_URL: postgres://myapp:${POSTGRES_PASSWORD}@db:5432/myapp_production
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - storage:/rails/storage
    expose:
      - "80"

  db:
    image: postgres:16.4-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: myapp_production
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 5s
      timeout: 5s
      retries: 10

  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app

volumes:
  pgdata:
  storage:
  caddy_data:
  caddy_config:

The app service connects to db by its service name, not localhost, since Compose puts both containers on the same private network. The depends_on: condition: service_healthy line keeps the app from starting before Postgres can actually accept connections, not just before its container exists. Named volumes keep your database and Active Storage files on disk across restarts and rebuilds.

Check the file parses cleanly before going further:

docker compose config

You should see the fully resolved configuration printed with no error lines.

Step 7: Add Caddy as a reverse proxy with HTTPS

Add a Caddyfile next to compose.yml:

myapp.example.com {
	reverse_proxy app:80
}

Replace myapp.example.com with the domain you pointed at the droplet in step 2. Caddy reads this and does two things on its own: it proxies every request to the app container’s port 80, and it requests a free TLS certificate from Let’s Encrypt for that exact hostname, renewing it automatically before it expires. This only works if the domain already resolves to this droplet, which is why DNS came before Caddy in this guide.

Step 8: Boot the stack

From /opt/myapp:

docker compose build
docker compose up -d db
docker compose run --rm app bin/rails db:prepare
docker compose up -d

bin/rails db:prepare creates the database if it doesn’t exist yet and runs any pending migrations, which is the same command Railyard runs before every release later in this guide.

Check the stack:

docker compose ps
curl -I https://myapp.example.com

You should see all three services as Up (and db as healthy), and the curl should return HTTP/2 200 with no certificate warning.

Step 9: Back up Postgres

A dump taken outside the data volume is what actually protects you if the droplet disappears. Add a backup script:

cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp_production | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh

Schedule it with cron, daily at 3am:

(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -

Run it once by hand and check it worked:

/opt/myapp/backup.sh
ls -lh /opt/myapp/backups

You should see a .sql.gz file with today’s timestamp and a non-zero size. For anything beyond a personal project, copy these files off the droplet too, with rclone to DigitalOcean Spaces or another object store, so a lost droplet doesn’t also mean a lost backup.

Step 10: Ship an upgrade

Every later change follows the same shape: get the new code onto the droplet, rebuild the image, run pending migrations, then replace the running container.

git pull
docker compose build app
docker compose run --rm app bin/rails db:prepare
docker compose up -d app

docker compose up -d app only recreates the app container, not db or caddy, so Postgres keeps running and Caddy’s certificate state is untouched. There’s a short gap, a second or two, while the old container stops and the new one starts, since this manual setup runs a single app container. Check it landed:

docker compose logs --tail=50 app
curl -I https://myapp.example.com

You should see your app boot cleanly in the logs and the curl still returning 200 on the new version.

The shortcut: do it with Railyard

Everything above, droplet sizing, Docker, Postgres, Caddy, backups, zero-downtime releases, is also what Railyard automates for a Rails app on DigitalOcean, without you writing any of the files above by hand.

Paste your repository’s URL into https://app.railyard.run/deploy?repo= and pick DigitalOcean as the provider. The first time, you’ll generate a token in the DigitalOcean control panel (open API, then generate a personal access token with read and write access) so Railyard can provision the server on your behalf. Railyard defaults this combination to the same s-1vcpu-2gb droplet used in this guide, and you can pick any DigitalOcean region, including Frankfurt or Amsterdam if EU data residency matters to you.

From there, Railyard reads your app the same way this guide did by hand: it sees pg in your Gemfile.lock and provisions a managed Postgres database instead of SQLite, it runs bin/rails db:prepare before every release the same way step 8 did manually, and if you’re on Rails 8 it gives the cache, queue and cable databases their own connection strings instead of sharing one. Active Storage on disk gets a persistent volume automatically, matching the storage volume from step 6. SECRET_KEY_BASE is generated once and kept, so you never manage it by hand. If your Gemfile.lock includes Sidekiq, Solid Queue, GoodJob, Resque or Delayed Job, Railyard offers it as a worker process alongside your web process, no separate compose service to write. HTTPS and the reverse proxy from step 7 are handled the same way, pointed at your domain once you add it.

Read more in deploying from a repository, databases and backups, the one-click deploy button, getting started, the Rails on DigitalOcean reference guide, and hosting cost comparisons if you want to see how the droplet-only cost here compares to a managed path. Railyard’s Free plan covers one server plus a database server and three apps with no card required, Pro is $19/month per team, and Team is $49/month per team.

Troubleshooting

Port already in use. If docker compose up fails with something binding to port 80 or 443, Ubuntu’s default Apache or Nginx may already be installed and running. Check with ss -tlnp | grep -E ':80|:443' and stop or remove whichever service owns them before Caddy tries to bind.

Wrong RAILS_MASTER_KEY or credentials. An error like ActiveSupport::MessageEncryptor::InvalidMessage on boot almost always means the RAILS_MASTER_KEY in /opt/myapp/.env doesn’t match the key that encrypted config/credentials.yml.enc. Re-run cat config/master.key locally and compare it character for character with what’s in .env on the droplet.

Postgres connection refused. If the app logs show could not connect to server: Connection refused, check that DATABASE_URL uses db as the hostname, not localhost or 127.0.0.1. Inside Compose’s network, db is the only address that resolves to the Postgres container; localhost means the app container itself, which has no database running in it.

HTTPS not issuing. If docker compose logs caddy shows repeated certificate errors or NXDOMAIN, your DNS record from step 2 either hasn’t propagated yet or is pointing at the wrong IP. Re-run dig +short myapp.example.com, confirm it matches the droplet, then restart Caddy with docker compose restart caddy once it does.

Out of memory during asset precompile. On a 2 GB droplet, docker compose build can get killed partway through asset compilation on a larger app. Add a 2 GB swap file before building: fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile, then make it permanent by adding it to /etc/fstab.

FAQ

Is a 2 GB droplet enough for production? For a small to mid-sized Rails app with modest traffic, yes, since Puma and Postgres together fit comfortably, and the troubleshooting swap fix above covers the one case (large asset builds) where 2 GB gets tight.

Can I skip Postgres and keep SQLite? You can, SQLite works fine on a single server as long as you mount its database file on a persistent volume so it survives container restarts, but Postgres gives you a cleaner path if you ever add a second app server.

How do I add Redis or Sidekiq? Add a redis service to compose.yml using the official redis image with its own named volume, add REDIS_URL to app’s environment pointing at redis://redis:6379/0, and add a worker service that runs bundle exec sidekiq against the same image instead of the Rails server command.

Does this survive a droplet reboot? Yes, restart: unless-stopped on every service means Docker brings the whole stack back up automatically after the droplet restarts, as long as Docker’s own systemd service is enabled, which the install script in step 3 does by default.

How is this different from using Heroku or Railway? Those platforms manage the server for you at a price per app; this guide gives you the same result on a server you pay DigitalOcean for directly, at the cost of running the upgrade and backup commands yourself instead of a dashboard doing it.

That’s a full production setup: a droplet, Docker Compose running your app and Postgres, Caddy handling HTTPS, a nightly backup, and a repeatable three-command release. Keep this compose.yml and Caddyfile in your repository alongside the app, and every future deploy is the same git pull, build, migrate, restart you just ran in step 10.

Why people pick Railyard for this

  • Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
  • One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
  • One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
  • Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
  • Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.

Skip the droplet setup

Paste your repository's URL into Railyard and get Postgres, a worker, HTTPS and backups configured for you.

Free plan, no card, and 14 days of Pro for every new team. One flat price per team.