Isometric illustration of requests passing through a green encrypted tunnel with a padlock on their way to a server

To force HTTPS in Rails, set config.force_ssl = true in config/environments/production.rb. Rails then redirects plain HTTP to HTTPS, sends a Strict-Transport-Security (HSTS) header, and marks cookies as Secure. Behind a TLS-terminating proxy, which is how almost every Rails app runs today, you also have to decide who does the redirect: Rails, using the proxy’s X-Forwarded-Proto header, or the proxy itself, with config.assume_ssl = true telling Rails every request is already HTTPS. Get that wrong and you’ll see redirect loops, failing health checks or CSRF errors. This guide shows the right configuration per platform, a safe HSTS rollout, and how to fix each failure.


TL;DR

  • config.force_ssl = true gives you three things: HTTP→HTTPS redirects, an HSTS header (2 years, including subdomains, by default) and Secure cookies.
  • If the proxy redirects HTTP to HTTPS itself (kamal-proxy with ssl: true, Render, Fly.io with force_https, Upsun), also set config.assume_ssl = true. That’s the Rails 8 default.
  • If the proxy doesn’t redirect (Heroku’s router), keep assume_ssl off and let force_ssl redirect based on X-Forwarded-Proto.
  • Roll out HSTS gradually: start with a short max-age, then go to 1–2 years, and only add preload when every subdomain serves HTTPS.
  • Exclude /up from the redirect, and avoid Cloudflare’s “Flexible” SSL mode, which causes infinite redirect loops.

Table of Contents

What does force_ssl do in Rails?

config.force_ssl = true inserts the ActionDispatch::SSL middleware, which does three things for every request.

  1. Redirects HTTP to HTTPS. GET and HEAD requests get a 301 Moved Permanently. Other methods get a 307 Temporary Redirect, so the browser repeats the POST over HTTPS instead of turning it into a GET.
  2. Sends HSTS. Strict-Transport-Security: max-age=63072000; includeSubDomains tells browsers to use HTTPS for this host, and all its subdomains, for the next two years without even trying HTTP.
  3. Marks cookies Secure, so session and other cookies are never sent over plain HTTP.

The redirect only happens when Rails believes the request came in over plain HTTP. Behind a proxy, that belief depends on headers and on assume_ssl, which is where most HTTPS problems come from.

force_ssl vs assume_ssl: which one do you need?

Use force_ssl in every production app. Add assume_ssl only when the proxy in front of Rails already redirects plain HTTP to HTTPS, because with assume_ssl on, Rails treats every request as HTTPS and never redirects.

Where TLS ends and how Rails knows the request was HTTPS, with force_ssl outcomes

Setting What it does Use when
force_ssl = true Redirects HTTP→HTTPS, sends HSTS, marks cookies Secure Always in production
assume_ssl = true (Rails 7.1+) Treats every request as HTTPS, regardless of headers The proxy terminates TLS and redirects HTTP itself, or doesn’t send X-Forwarded-Proto
Neither header nor assume_ssl Rails sees http:// for every request Never: causes redirect loops with force_ssl

Without either, a TLS-terminating proxy forwards the decrypted request as plain HTTP. Rails sees http://, force_ssl redirects to https://, the browser follows the redirect to the same proxy, and the loop starts again. X-Forwarded-Proto: https from the proxy, or assume_ssl, breaks the loop.

Prerequisites

  • Rails 7.1+ for assume_ssl (examples use Rails 8.0 and Ruby 3.3).
  • A domain with a TLS certificate. Every platform below issues one automatically with Let’s Encrypt or its own CA.
  • Access to config/environments/production.rb and your proxy or platform settings.

Step 1: Configure HTTPS in production.rb

Pick the block that matches who redirects plain HTTP.

The proxy redirects HTTP itself (Kamal 2, Render, Fly.io with force_https, Upsun). This is the Rails 8 default:

# config/environments/production.rb
Rails.application.configure do
  # TLS ends at the proxy, which also redirects http:// -> https://.
  config.assume_ssl = true

  # HSTS + Secure cookies (no redirect needed, every request is HTTPS to Rails).
  config.force_ssl = true
  config.ssl_options = {
    hsts: { expires: 1.year, subdomains: true, preload: false }
  }
end

The proxy doesn’t redirect (Heroku’s router, or a load balancer that forwards port 80 to the app):

# config/environments/production.rb
Rails.application.configure do
  # Rails decides from X-Forwarded-Proto, which the proxy must set.
  config.assume_ssl = false
  config.force_ssl = true
  config.ssl_options = {
    hsts: { expires: 1.year, subdomains: true, preload: false },
    # Health checks often arrive as plain HTTP from inside the network.
    redirect: { exclude: ->(request) { request.path == "/up" } }
  }
end

ssl_options reference:

Option Default Notes
hsts: { expires: } 2 years Start lower during rollout (Step 2)
hsts: { subdomains: } true Every subdomain must serve HTTPS
hsts: { preload: } false Only with expires ≥ 1 year and subdomains: true
hsts: false — Disables the header entirely
redirect: { exclude: ->(req) { … } } none Skip redirect for health checks, ACME challenges
redirect: { status: } 301 / 307 Rarely needs changing
secure_cookies: true Keep it

Health check behaviour across platforms is covered in the Rails health check guide.

Step 2: Roll out HSTS safely

HSTS is cached by browsers for max-age seconds, and you can’t take it back early. If a subdomain doesn’t support HTTPS yet, includeSubDomains with a two-year max-age breaks it for every visitor for two years. Increase it in stages:

Stage ssl_options[:hsts] Duration before next stage
1. Test { expires: 5.minutes, subdomains: false } A day
2. Short { expires: 1.week, subdomains: true } A week, after checking every subdomain
3. Long { expires: 1.year, subdomains: true } A month
4. Preload (optional) { expires: 2.years, subdomains: true, preload: true } Submit at hstspreload.org

Before stage 2, list every subdomain that has DNS records (staging, mail, docs, old marketing sites) and confirm each one serves HTTPS with a valid certificate.

Preloading puts your domain in the HSTS list compiled into browsers, so even the very first visit uses HTTPS. It’s effectively permanent. Removal takes months to reach users, so only preload a domain you’re sure will stay HTTPS-only on every subdomain.

Pro Tip: HSTS on example.com with includeSubDomains doesn’t protect example.com if users type www.example.com first and www is served by a different system. Make sure the apex domain itself sends the header, since that’s what the preload list checks.

Step 3: Fix mixed content and secure cookies

Once pages load over HTTPS, browsers block or warn about resources loaded over HTTP. Fix the sources, and add a CSP directive that upgrades stragglers.

# config/environments/production.rb
config.asset_host = "https://cdn.example.com"   # never "http://" or protocol-relative
config.action_mailer.default_url_options = { host: "example.com", protocol: "https" }
# config/initializers/content_security_policy.rb
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.upgrade_insecure_requests true
    # ...the rest of your policy
  end
end

upgrade_insecure_requests makes the browser fetch http:// subresources over https:// automatically. It’s a safety net for old content in your database, such as user-generated HTML with image URLs. It’s not a substitute for fixing your own templates.

force_ssl already marks cookies Secure. For the session cookie, also keep SameSite=Lax (the Rails default) and HttpOnly:

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: "_myapp_session", secure: Rails.env.production?, same_site: :lax, httponly: true

Action Cable connects over wss:// automatically when the page is served over HTTPS. If you set config.action_cable.url, use wss://.

Step 4: Configure the proxy or platform

The proxy must terminate TLS with a valid certificate and either redirect HTTP itself or tell Rails the original scheme.

Kamal 2. ssl: true makes kamal-proxy obtain a Let’s Encrypt certificate and redirect HTTP to HTTPS:

# config/deploy.yml
proxy:
  ssl: true
  host: example.com
  # ssl_redirect: true   # default when ssl is on

For multiple domains, use hosts: [example.com, www.example.com]. Port 80 must be reachable from the internet so Let’s Encrypt’s HTTP-01 challenge succeeds. The full setup is in minimal Rails deployments with Kamal 2 and Thruster.

Fly.io. Let the proxy redirect, and add a certificate for your custom domain:

# fly.toml
[http_service]
  internal_port = 3000
  force_https = true
fly certs add example.com
fly certs show example.com

Render. Add the custom domain in the dashboard. Render issues the certificate and redirects HTTP to HTTPS for you.

Upsun. Declare HTTPS routes. HTTP is redirected to HTTPS by default:

# .upsun/config.yaml (routes excerpt)
routes:
  "https://{default}/":
    type: upstream
    upstream: "myapp:http"
  "https://www.{default}/":
    type: redirect
    to: "https://{default}/"

Heroku. Enable Automated Certificate Management. The router doesn’t redirect HTTP, so Rails does it with force_ssl (the second production.rb block above):

heroku certs:auto:enable -a myapp
heroku domains:add example.com -a myapp

Behind Cloudflare. Use SSL/TLS mode Full (strict), so Cloudflare connects to your origin over HTTPS and validates its certificate. Flexible mode connects to the origin over plain HTTP, which combined with force_ssl creates an endless redirect loop.

Step 5: Verify it works

Check the redirect, the HSTS header, cookies and the certificate from outside, then the health check from inside.

# 1. HTTP redirects to HTTPS, keeping the path
curl -sI http://example.com/pricing | grep -iE "^HTTP|^location"
# HTTP/1.1 301 Moved Permanently
# location: https://example.com/pricing

# 2. HSTS header present with the expected max-age
curl -sI https://example.com/ | grep -i strict-transport-security
# strict-transport-security: max-age=31536000; includeSubDomains

# 3. Cookies are Secure
curl -sI https://example.com/session/new | grep -i set-cookie
# set-cookie: _myapp_session=...; path=/; secure; httponly; samesite=lax

# 4. Certificate is valid and not about to expire
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -subject -dates

# 5. Health check still returns 200 (no redirect)
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/up

For a full report of protocol versions, ciphers and HSTS, run the domain through SSL Labs’ server test. Aim for an A or A+.

How Heroku, Render, Fly.io, Upsun and Enkihost handle HTTPS

All five platforms issue and renew TLS certificates automatically. The key difference for Rails is whether the platform redirects HTTP to HTTPS itself, which decides whether you need assume_ssl.

Platform Certificates Redirects HTTP→HTTPS Sets X-Forwarded-Proto Rails config
Heroku Automated Certificate Management (Let’s Encrypt) No, the app must Yes force_ssl = true, assume_ssl = false
Render Automatic for custom domains Yes Yes assume_ssl = true, force_ssl = true
Fly.io fly certs add (Let’s Encrypt) With force_https = true Yes assume_ssl = true, force_ssl = true
Upsun Automatic Let’s Encrypt, renewed before expiry Yes, by default for https:// routes Yes assume_ssl = true, force_ssl = true
Kamal 2 kamal-proxy with Let’s Encrypt (ssl: true) Yes (ssl_redirect) Yes assume_ssl = true, force_ssl = true (Rails 8 default)
Enkihost Automatic Let’s Encrypt at the proxy Not documented Yes (Traefik) force_ssl = true; check the redirect with Step 5 before setting assume_ssl

Heroku is the outlier. Its router doesn’t redirect, so an app with assume_ssl = true on Heroku serves pages over plain HTTP to anyone who types http://, with no redirect at all. That’s an easy mistake when a Rails 8 app generated for Kamal is deployed to Heroku unchanged.

Enkihost serves Rails apps behind its own proxy with zero-downtime deploys. The Rails-side checks in this guide (force_ssl, HSTS, secure cookies, and the curl tests in Step 5) apply there too, so verify the redirect with Step 5 to choose the right assume_ssl setting.

Troubleshooting HTTPS in Rails

ERR_TOO_MANY_REDIRECTS

The browser follows https:// → (proxy forwards as HTTP) → Rails redirects to https:// → … forever. Rails never learns the request was HTTPS. Fix it with one of:

  • config.assume_ssl = true, if the proxy itself redirects HTTP.
  • Make the proxy send X-Forwarded-Proto: https (Nginx: proxy_set_header X-Forwarded-Proto $scheme;).
  • Behind Cloudflare, switch from Flexible to Full (strict).

Health checks fail with 301

The platform’s health check hits the container over plain HTTP, and force_ssl redirects it. Add redirect: { exclude: ->(r) { r.path == "/up" } } to ssl_options, or set assume_ssl if the proxy handles redirects.

HTTP Origin header didn't match request.base_url

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

Rails thinks the request is HTTP, so the CSRF origin check fails on every form submission. That’s the same root cause as the redirect loop: set assume_ssl or fix X-Forwarded-Proto.

A subdomain stopped working after enabling HSTS

includeSubDomains forced browsers to use HTTPS on a subdomain that doesn’t support it. Give that subdomain a certificate. You can’t shorten the HSTS period for visitors who already received the header. Reducing max-age only affects new visits. This is why the staged rollout in Step 2 matters.

Let’s Encrypt certificate isn’t issued (Kamal)

kamal-proxy needs port 80 reachable for the HTTP-01 challenge, and DNS must point to the server before you deploy. Check kamal proxy logs for ACME errors, confirm ufw allows 80/tcp, and make sure no CDN in front intercepts /.well-known/acme-challenge/.

Mixed content warnings

Mixed Content: The page at 'https://example.com/' was loaded over HTTPS, but requested an insecure image 'http://cdn.example.com/logo.png'.

A template, asset_host or database content uses http://. Fix the URL, set asset_host to https://, and add upgrade_insecure_requests to your CSP as a safety net.

Author Perspective: let one layer own the redirect

Almost every HTTPS bug I’ve debugged in Rails apps came from two layers both trying to be in charge of the redirect, or neither. The fix is to decide explicitly: either the proxy redirects and Rails assumes SSL, or Rails redirects based on X-Forwarded-Proto and the proxy just forwards. Write that decision in a comment next to assume_ssl in production.rb, because the next platform migration will otherwise break it. I’m also more careful with HSTS than I used to be. A five-minute max-age for the first day costs nothing, and a typo in a two-year includeSubDomains header can’t be undone.

HTTPS on Enkihost

HTTPS done right is mostly configuration, and it should stay the same between platforms. On Enkihost:

Enkihost

  • Zero-downtime deploys, so enabling force_ssl or moving through the HSTS stages in Step 2 is a normal deploy, without dropped requests.
  • Per-app resource isolation, so TLS handshakes and redirects for other apps don’t share your app’s allocated CPU and memory.
  • PostgreSQL and Redis add-ons that inject DATABASE_URL and REDIS_URL, so encrypted connection settings like sslmode stay in one environment variable.
  • 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

What is the difference between force_ssl and assume_ssl in Rails?

force_ssl redirects HTTP requests to HTTPS, adds the HSTS header and marks cookies as secure. assume_ssl, added in Rails 7.1, makes Rails treat every request as HTTPS regardless of headers. Use assume_ssl only when the proxy in front of Rails terminates TLS and already redirects plain HTTP.

Does force_ssl work behind a load balancer?

Yes, as long as Rails can tell the original request was HTTPS. The load balancer must send X-Forwarded-Proto: https, or you set config.assume_ssl to true if the load balancer redirects HTTP itself. Without either, force_ssl causes an infinite redirect loop.

What HSTS max-age should I use?

Start with a few minutes to test, then a week, then one year once every subdomain serves HTTPS. Rails defaults to two years with includeSubDomains. Use a year or more if you plan to submit your domain to the HSTS preload list, which requires includeSubDomains and the preload flag.

Do I need force_ssl if my platform already redirects to HTTPS?

Yes. Even when the proxy handles redirects, force_ssl is what adds the HSTS header and marks cookies as secure, which protect users from downgrade attacks and cookie leaks. Combine it with assume_ssl so Rails doesn’t try to redirect requests that are already HTTPS.

How do I test HTTPS locally in Rails?

You usually don’t need to, because force_ssl is only enabled in production. To test HTTPS-specific behaviour, generate a local certificate with mkcert and start Puma with an ssl bind, or use a tunnelling tool that provides an HTTPS URL. Keep development on plain HTTP otherwise.

Sources