What force_ssl actually does
This application already runs on HTTPS, and three lines in config/environments/production.rb are why:
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = true
# Skip http-to-https redirect for the default health check endpoint.
config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
Most people read those, agree, and move on. They deserve a closer reading, because between them they decide whether your users are protected and whether your deploys work.
The termination model
The Rails process almost never speaks TLS. Something in front of it, such as kamal-proxy, an ingress controller or a cloud load balancer, accepts the encrypted connection, decrypts it, and forwards plain HTTP over a private network.
flowchart LR
B["Browser"] -->|"HTTPS :443"| P["kamal-proxy / ingress / load balancer"]
P -->|"HTTP :3000, private network"| R["Rails / Puma"]
P -.->|"X-Forwarded-Proto: https"| R
C[("certificate + private key")] --- P
This is called TLS termination, and it explains the first config line. As far as Puma is concerned, every request arrived over plain HTTP. Rails would conclude the connection is insecure, refuse to set secure cookies, and, with force_ssl on, redirect to HTTPS forever.
assume_ssl = true tells Rails to treat every request as secure. Alternatively, the proxy sets X-Forwarded-Proto: https and Rails trusts it. Either works. Having neither is what fails, and the symptom is an infinite redirect loop that only happens in production.
assume_ssl comes with a caution: it is an unconditional assumption. If a request can reach Puma without passing the proxy, through a health check to the pod's IP or a container port exposed by accident, Rails treats that plaintext request as secure. The mitigation is boring and effective: the app's port is never reachable from outside the private network.
What force_ssl turns on
force_ssl = true is three behaviors, not one:
First, the redirect. HTTP requests get a 301 to the https:// URL. That protects a user who typed the domain without a scheme, but only after one unprotected request has already happened.
Second, Strict-Transport-Security. A response header tells the browser to use HTTPS for this host, and its subdomains, for a period without asking. This closes the gap above: after the first visit, the browser upgrades before sending anything.
Strict-Transport-Security: max-age=63072000; includeSubDomains
Third, secure cookies. Session and other cookies are marked secure, so the browser will not send them over plain HTTP at all. Without this, a single downgraded request leaks a session cookie, and the redirect does not help, because the cookie goes out with the request that gets redirected.
Of the three, the cookie flag is the one that matters most and gets discussed least.
The health check exclusion
config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
A load balancer or Kubernetes probe checks /up over plain HTTP on the internal network. If that request is redirected, the probe gets a 301, and a probe that expects 200 reads a 301 as a failure. The platform concludes the pod is unhealthy and restarts a working application in a loop. The Rails 8 generator ships this line, commented out, for that reason.
In this application the line is a safety net, not the thing keeping /up alive. Rails puts ActionDispatch::AssumeSSL in the middleware stack ahead of ActionDispatch::SSL, so with assume_ssl on, every request already counts as HTTPS and nothing is redirected. The exclusion starts to matter the day assume_ssl is turned off and the proxy probes over plain HTTP.
The exclusion covers more than the redirect. An excluded request that does count as HTTPS still gets the HSTS header, but its cookies are not flagged secure.
What this course covers
The three lines above assume a valid certificate exists. This course is about where it comes from and how it stays valid without anyone remembering to renew it.
- What
force_sslturns on (this lesson). - Certificates and the chain of trust.
- ACME and Let's Encrypt.
- Automating renewal.
- Terminating TLS, and what Rails sees.
- Wildcards, multiple domains, and the usual mistakes.
- Putting it together: HTTPS for this application.
Key takeaways
- TLS is terminated by a proxy; Rails receives plain HTTP over a private network.
assume_sslstops Rails from concluding the connection is insecure. Without it or a trustedX-Forwarded-Proto, you get a redirect loop.force_ssldoes three things: redirect, HSTS, and secure cookies. The cookie flag is the one that prevents a session leak.- Excluding
/upfrom the redirect keeps an HTTP health check from reading a 301 as a failure; withassume_sslon, nothing is redirected in the first place.
Keep going with lesson 2
Certificates and the chain of trust
What a certificate contains, why servers must send the intermediate, and how to diagnose common certificate errors.
A free account also gives you the quizzes, your progress and notes, and the discussion.