Isometric illustration of a firewall wall and a shield protecting containers, a server and a database

A secure Rails deployment means closing the gaps at every layer between the internet and your data: no known-vulnerable gems, no secrets in the repository or the image, strict hosts and security headers, rate limits on login and password reset, a hardened server or container, and a database nobody can reach from the internet. Rails ships safe defaults for most of the application layer, and a PaaS takes care of the operating system, but neither covers everything. This checklist goes layer by layer, with the exact configuration and a command to verify each item, for apps on Heroku, Render, Fly.io, Upsun or your own servers with Kamal.


TL;DR

  • Run bin/brakeman and bundler-audit in CI on every pull request, and keep Ruby, Rails and gems on supported versions. Rails 7.2+ generates both checks for new apps.
  • Keep secrets in per-environment credentials or the platform’s secret store, never in the image, the repo or logs. Set config.hosts, force_ssl and a Content Security Policy in production.
  • Use Rails 8’s built-in rate_limit on sign-in, sign-up, password reset and API endpoints.
  • On your own servers: SSH keys only, no root login, a firewall that exposes only 22/80/443, automatic security updates, and containers running as a non-root user.
  • Never expose PostgreSQL or Redis to the internet. Use a private network, TLS, a least-privilege role and tested backups.

Table of Contents

What does a secure Rails deployment involve?

A secure Rails deployment covers six layers: the network edge, the host, the container, the application, the secrets and the data. Each one has its own risks, and a weakness in any of them is enough.

Security layers of a Rails deployment: risks, controls and what a PaaS handles

Layer Typical failure Control in this guide
Edge Plain HTTP, credential stuffing TLS + HSTS, rate_limit (Steps 3, 4)
Host Unpatched OS, SSH brute force Key-only SSH, firewall, unattended upgrades (Step 5)
Container App running as root, secrets in layers Non-root user, runtime-only secrets (Steps 2, 5)
Application Vulnerable gem, XSS, mass assignment Brakeman, bundler-audit, CSP, host authorization (Steps 1, 3)
Secrets Leaked master key or API token Per-environment credentials, secret store (Step 2)
Data Public database port, superuser app role Private network, TLS, least privilege (Step 6)

Rails covers a lot by default: CSRF protection, escaped output in views, parameterised queries in Active Record, strong parameters, signed and encrypted cookies. Most real incidents come from the parts around it, which is what this checklist focuses on.

Prerequisites

  • Rails 7.2+ (examples use Rails 8.0/8.1 and Ruby 3.3). On older versions, add the gems manually.
  • A CI pipeline (GitHub Actions in the examples).
  • SSH access to your servers if you deploy with Kamal or to a VPS.

Step 1: Scan dependencies and code in CI

Most exploitable Rails vulnerabilities come from outdated dependencies, so catch them automatically. Brakeman finds risky patterns in your own code. bundler-audit checks Gemfile.lock against the Ruby advisory database.

# Gemfile
group :development, :test do
  gem "brakeman", require: false
  gem "bundler-audit", require: false
end
# .github/workflows/ci.yml
name: CI
on: [pull_request, push]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Brakeman (static analysis)
        run: bin/brakeman --no-pager --exit-on-warn
      - name: bundler-audit (vulnerable gems)
        run: bundle exec bundler-audit check --update
      - name: importmap audit (vulnerable JS packages)
        run: bin/importmap audit

New Rails 7.2+ apps generate a similar workflow, plus bin/brakeman. Keep it, and make the security job required for merging.

Then keep versions current:

  • Ruby and Rails must be on versions that still receive security fixes. Check the Rails maintenance policy and Ruby’s branch status before each quarter.
  • Enable Dependabot or Renovate for Bundler, npm and GitHub Actions, so security updates arrive as pull requests.
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: bundler
    directory: "/"
    schedule:
      interval: weekly
  - package-ecosystem: github-actions
    directory: "/"
    schedule:
      interval: weekly

Step 2: Keep secrets out of code, images and logs

Secrets belong in encrypted credentials or the platform’s secret store, and they should reach the app only at runtime.

  • Use per-environment credentials and a separate RAILS_MASTER_KEY per environment, as described in the Rails credentials guide.
  • Use the platform’s secret store for environment variables, and validate them at boot, as in how to manage environment variables in Rails.
  • Build images with SECRET_KEY_BASE_DUMMY=1 and exclude config/master.key, config/credentials/*.key and .env* in .dockerignore, as in how to dockerize a Rails app.
  • Filter sensitive parameters from logs and error reports:
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]

Add secret scanning to CI so a pasted key is caught before it reaches the default branch:

# .github/workflows/ci.yml (another job)
  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Step 3: Lock down production configuration

A handful of production settings close entire classes of attacks: only answer for your own hostnames, only over HTTPS, and only load scripts you trust.

# config/environments/production.rb
Rails.application.configure do
  # HTTPS everywhere, behind a TLS-terminating proxy.
  config.assume_ssl = true
  config.force_ssl = true
  config.ssl_options = { hsts: { expires: 1.year, subdomains: true }, redirect: { exclude: ->(r) { r.path == "/up" } } }

  # Only respond to your own domains (prevents host header attacks, DNS rebinding).
  config.hosts = ["example.com", /.*\.example\.com/]
  config.host_authorization = { exclude: ->(r) { r.path == "/up" } }

  # Don't leak stack traces or internals.
  config.consider_all_requests_local = false

  # Log at info, never debug, in production.
  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
end

HTTPS configuration in detail, including HSTS preload and redirects behind each platform’s proxy, gets its own guide later this month.

Content Security Policy. CSP tells the browser which sources may load scripts, styles and images. That makes most XSS bugs much harder to exploit. Rails generates a commented-out initializer. Enable it:

# config/initializers/content_security_policy.rb
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.script_src  :self
    policy.style_src   :self
    policy.img_src     :self, :data, "https://cdn.example.com"
    policy.font_src    :self, :data
    policy.connect_src :self
    policy.object_src  :none
    policy.frame_ancestors :none
    policy.base_uri    :self
    policy.form_action :self
  end

  # Allow importmap and inline Turbo scripts with a per-request nonce.
  config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
  config.content_security_policy_nonce_directives = %w[script-src style-src]

  # Start in report-only mode, fix violations, then enforce.
  config.content_security_policy_report_only = ENV["CSP_REPORT_ONLY"] == "1"
end

Use javascript_importmap_tags and csp_meta_tag in the layout so the nonce is applied. Roll out with CSP_REPORT_ONLY=1 first, watch the browser console and reports for a few days, then enforce.

Other headers. Rails already sends X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin by default. Add a Permissions-Policy that disables browser features you don’t use:

# config/initializers/permissions_policy.rb
Rails.application.config.permissions_policy do |policy|
  policy.camera      :none
  policy.microphone  :none
  policy.geolocation :none
  policy.usb         :none
  policy.payment     :self
end

Step 4: Rate-limit authentication and expensive endpoints

Rails 8 has a built-in rate_limit for controllers, backed by Rails.cache. Put it on every endpoint that attackers would hammer: sign-in, sign-up, password reset, OTP verification, and expensive searches or exports.

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create,
             with: -> { redirect_to new_session_url, alert: "Too many attempts. Try again in a few minutes." }

  def create
    # ...
  end
end
# app/controllers/passwords_controller.rb
class PasswordsController < ApplicationController
  rate_limit to: 5, within: 15.minutes, only: :create, by: -> { params[:email].to_s.downcase }
end
# app/controllers/api/base_controller.rb
class Api::BaseController < ActionController::API
  rate_limit to: 300, within: 1.minute, by: -> { request.headers["Authorization"].to_s }
end

Three things make rate limiting actually work in production:

  • A shared cache store (Solid Cache or Redis). With :memory_store, each Puma process counts separately, and the limit is effectively multiplied by your process count.
  • The right client IP. The default key is request.remote_ip. Behind a load balancer, set config.action_dispatch.trusted_proxies correctly, or every request looks like it comes from the proxy and one attacker can lock out all users.
  • Limits per account, not just per IP, for password reset and OTP, using by:. Attackers rotate IPs. They can’t rotate your user’s email address.

For blocking at the edge before requests reach Rails, a CDN or WAF (Cloudflare, Fastly) or rack-attack adds IP blocklists and throttles for the whole app.

Step 5: Harden the server and container

On a PaaS, the provider hardens and patches the host. With Kamal or a VPS, you do. These are the minimum steps for a Debian or Ubuntu server.

# Create a deploy user with sudo, add your SSH key, then:
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
sudo systemctl reload ssh

# Firewall: only SSH, HTTP and HTTPS from the internet
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

# Automatic security updates
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

# Ban IPs that repeatedly fail SSH
sudo apt install -y fail2ban

One Docker-specific trap: Docker publishes container ports by editing iptables directly, which bypasses ufw. A postgres accessory published with port: 5432 is reachable from the internet even though ufw says otherwise. Bind accessories to localhost or the private interface:

# config/deploy.yml
accessories:
  db:
    image: postgres:17
    host: 10.0.0.5
    port: "127.0.0.1:5432:5432"   # not "5432:5432"
    env:
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data

Container hardening. The Rails 8 Dockerfile already does the important things: a slim base image, a multi-stage build without compilers in the final image, and USER 1000:1000 so the app doesn’t run as root. Keep those, rebuild regularly so base image security fixes get in, and scan the image:

docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --severity HIGH,CRITICAL myorg/myapp:latest

Step 6: Protect the database and Redis

PostgreSQL and Redis should never be reachable from the public internet. The app should connect over a private network, with TLS when it crosses machines, using a role that can only touch its own databases.

  • Network: bind to private interfaces only (listen_addresses = 'localhost,10.0.0.5'), and restrict pg_hba.conf to the app servers’ private IPs.
  • TLS: sslmode=require or verify-full in DATABASE_URL.
  • Least privilege: a dedicated app role that owns its databases, never postgres. A separate read-only role for reporting tools.
  • Redis: require a password (requirepass) or ACLs, bind to the private interface, and use rediss:// across networks.
  • Backups: encrypted, off the server, and restored regularly. A backup you haven’t restored is a hope, not a backup.

The full PostgreSQL setup, including roles and SSL, is in the Rails PostgreSQL setup guide.

Step 7: Verify it works

Check every layer from the outside, as an attacker would, and from the inside.

# 1. Code and dependencies
bin/brakeman --no-pager -q
bundle exec bundler-audit check --update

# 2. Only 22, 80 and 443 are open from the internet
nmap -Pn -p 1-10000 app.example.com
# 22/tcp open ssh · 80/tcp open http · 443/tcp open https

# 3. Database and Redis are NOT reachable from the internet
nc -zv -w 3 app.example.com 5432 ; nc -zv -w 3 app.example.com 6379
# both: Connection timed out / refused

# 4. Security headers
curl -sI https://example.com | grep -iE "strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy"

# 5. Unknown hosts are rejected
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" https://example.com/
# 403

# 6. Rate limit kicks in (password reset has no custom `with:`, so it answers 429)
for i in $(seq 1 7); do curl -s -o /dev/null -w "%{http_code} " -X POST https://example.com/passwords -d "email=a@example.com"; done; echo
# 302 302 302 302 302 429 429

# 7. SSH refuses passwords
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deploy@app.example.com
# Permission denied (publickey).

An online check such as securityheaders.com or Mozilla Observatory gives a quick second opinion on headers and TLS.

How Heroku, Render, Fly.io, Upsun and Enkihost share security responsibilities

Every PaaS takes over the host layer, meaning OS patches, the kernel and physical security, and provides TLS certificates. None of them secures your application code or your gem versions. The middle layers differ.

Responsibility Heroku Render Fly.io Upsun Enkihost Kamal / VPS
OS and kernel patching Platform Platform Platform Platform Platform You
TLS certificates Automated Certificate Management Automatic Automatic (fly certs) Automatic (Let’s Encrypt) Automatic (Let’s Encrypt at the proxy) kamal-proxy (Let’s Encrypt)
Private networking to the database Private Spaces (paid) Private network between services Private network (WireGuard, 6PN) Internal service network Internal network between the app, PostgreSQL and Redis You
DDoS protection Platform edge Platform edge Platform edge Platform edge; Fastly on some plans Not documented You / CDN
Base image updates Stack updates Native runtimes: platform; Docker: you You (your Dockerfile) Platform images You with your own Dockerfile; platform if it’s generated You
App code, gems, Rails config You You You You You You

The last row is the important one: even the most managed platform can’t keep your Gemfile.lock free of vulnerable gems or your controllers rate-limited. On Docker-based platforms (Fly.io, Render’s Docker runtime, Kamal), keeping the base image current is also your job, since the platform runs exactly the image you built.

Enkihost is shared hosting with per-app resource isolation and allocated memory and CPU, so the host layer is managed for you, and the application-layer steps in this guide (Steps 1–4 and 6) apply exactly as written.

Troubleshooting security settings

Blocked hosts: example.com

[ActionDispatch::HostAuthorization::DefaultResponseApp] Blocked hosts: www.example.com

The request used a hostname not in config.hosts. Add it (a regex such as /.*\.example\.com/ covers subdomains), and exclude /up so health checks by IP still work.

Refused to execute inline script because it violates the following Content Security Policy directive

Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'".

An inline <script> without a nonce. Use javascript_tag nonce: true, move the code into a module loaded by importmap, or, for third-party snippets, add their domain explicitly. Roll out in report-only mode first to find these.

ActionController::InvalidAuthenticityToken after enabling HTTPS

ActionController::InvalidAuthenticityToken (HTTP Origin header (https://example.com) didn't match request.base_url (http://example.com))

Rails thinks the request is HTTP because TLS ends at the proxy. Set config.assume_ssl = true, or make sure the proxy sends X-Forwarded-Proto: https.

Everyone gets 429 Too Many Requests at once

All requests share one IP from Rails’ point of view, the load balancer’s. Configure config.action_dispatch.trusted_proxies with your proxy’s addresses so request.remote_ip returns the client IP, or rate-limit by account with by:.

Brakeman reports a warning you’ve reviewed

Mark it as ignored with a note, so it doesn’t block CI but stays documented:

bin/brakeman -I   # interactive: writes config/brakeman.ignore with your note

Author Perspective: boring security wins

The security incidents I’ve seen in Rails projects were never clever. One was a gem two years out of date with a published exploit. One was a database port open to the internet because Docker bypassed the firewall. One was a password reset endpoint without a rate limit. All three would have been caught by this checklist, and most of it runs automatically once it’s set up: Brakeman and bundler-audit on every pull request, Dependabot every week, unattended upgrades every night. The parts that need a person are a quarterly look at versions and an nmap run against your own servers. Both take less than an hour, and they’re the most valuable hour on the calendar.

Secure Rails hosting on Enkihost

Security is easier when there are fewer layers to own. On Enkihost:

Enkihost

  • Per-app resource isolation: each app runs with its own allocated memory and CPU, so another app’s problem doesn’t become yours.
  • PostgreSQL and Redis add-ons that inject DATABASE_URL and REDIS_URL, so credentials for your data stores come from the platform instead of being copied around by hand.
  • Zero-downtime deploys, so shipping a security update to a gem is a routine deploy, not a maintenance window you’re tempted to postpone.
  • Plans for Rails and Sinatra: Ignite at 9 EUR per month after a 14-day free trial, with PostgreSQL and Redis included, or Blaze at 29 EUR with high availability and autoscaling. The free Spark plan is for Jekyll sites.

See enkihost.com.

FAQ

Is Ruby on Rails secure by default?

Rails has strong defaults: CSRF protection, automatic HTML escaping, parameterised queries in Active Record, strong parameters and encrypted cookies. Most Rails vulnerabilities in practice come from outdated gems, custom code that bypasses these defaults, missing rate limits and insecure infrastructure, which is why the deployment checklist matters as much as the framework.

How often should I update gems for security?

Apply security updates as soon as they are published, ideally within days, and review other updates weekly. Running bundler-audit in CI and enabling Dependabot or Renovate makes this routine, because vulnerable versions fail the build and fixes arrive as pull requests.

Does Rails 8 include rate limiting?

Yes. Rails 8 controllers support rate_limit with a limit, a time window and an optional key, stored in Rails.cache. The Rails 8 authentication generator already applies it to the sessions controller. Use a shared cache store in production so the limit applies across all processes.

Do I need a firewall if I use a PaaS?

Not for the host, because the platform controls network access to its machines. You still need to make sure your database and Redis aren’t publicly accessible, which on most platforms means using the private network URL rather than an external one, and restricting any external access to specific IP addresses.

What is the most common security mistake in Rails deployments?

Running outdated dependencies with known vulnerabilities, closely followed by secrets committed to the repository and databases reachable from the internet. All three are preventable with automated checks: bundler-audit and Brakeman in CI, secret scanning, and a port scan of your own servers.

Sources