RUBY ON RAILS · 21 MIN READ ·

Rails Thruster: Replace Nginx with Rails 8's Built-In HTTP/2 Proxy in Production

Rails Thruster replaces Nginx as your HTTP/2 proxy in Rails 8 production. Configuration guide: TLS via ACME, compression, Kamal 2 integration, and tuning.

Rails Thruster: Replace Nginx with Rails 8's Built-In HTTP/2 Proxy in Production

Every new Rails 8 project I set up in 2025 triggered the same conversation with the client’s infrastructure team: “Wait, there’s no Nginx config? Where’s the reverse proxy?” The answer is that Rails 8 ships with one built in.

Rails Thruster is a lightweight HTTP/2 proxy written in Go that sits in front of Puma, handles TLS termination via ACME, compresses responses, and caches static assets — all without a separate process you have to configure, deploy, or maintain. It is not a replacement for every Nginx use case. But for a standard Rails 8 application deployed with Kamal 2 on a single server or a small cluster, Thruster eliminates an entire layer of infrastructure that most teams were keeping around out of habit.

After nineteen years of Rails, I have configured a lot of Nginx. The proxy_pass, gzip on, location /assets, keepalive_timeout blocks that appear in every Rails Nginx config look different when you realise they are solving problems that Thruster already handles.

What Rails Thruster Actually Does

Thruster is a Go binary that wraps your Puma process. It starts Puma as a subprocess, binds to port 80 and 443 externally, and proxies requests through to Puma on an internal port. When a response comes back, Thruster handles:

  • HTTP/2 support — Thruster speaks HTTP/2 to clients. Puma behind it still uses HTTP/1.1 internally. You get multiplexed connections and header compression to the browser without any change to your Rails app.
  • TLS termination via ACME — point Thruster at a domain, and it will fetch and renew a Let’s Encrypt certificate automatically. No Certbot, no cron job, no manual renewal.
  • Response compression — Brotli for clients that support it, gzip as fallback. Compresses text responses above a configurable minimum size before they leave the server.
  • Static asset caching — responses with Cache-Control: public are cached in memory and served directly by Thruster on cache hits, bypassing Puma entirely. Your stylesheets and JavaScript bundles get served from the proxy on repeat requests.
  • X-Forwarded-For header management — trusted client IP injection handled correctly, no Nginx set_real_ip_from configuration required.

The total memory footprint of the Thruster proxy process is typically under 30 MB. For the class of applications that previously ran Nginx in a Docker sidecar or as a separate systemd service, this is a meaningful operational simplification.

The Default Rails 8 Setup

When you run rails new with Rails 8, the generated Dockerfile ends with:

# Expose Thruster's HTTP port
EXPOSE 80

# Use Thruster as the HTTP proxy in front of Puma
CMD ["./bin/thrust", "bundle", "exec", "puma", "-C", "config/puma.rb"]

The bin/thrust executable is installed by the thruster gem. Add it to your Gemfile if it is not already there:

# Gemfile
gem "thruster", require: false

Run the generator to create the binstub:

bundle install
bundle binstubs thruster

That is the entire Rails-side setup. When your container starts, Thruster launches, binds to port 80, starts Puma as a child process on port 3000, and proxies traffic between them.

In development, you do not use Thruster — bin/dev still runs Puma directly on localhost:3000. Thruster is a production concern only.

Configuring Thruster via Environment Variables

Thruster is configured entirely through environment variables. The relevant ones for a standard production deployment:

# Port Thruster listens on for HTTP (default: 80)
HTTP_PORT=80

# Port Thruster listens on for HTTPS (default: 443)
HTTPS_PORT=443

# Internal port Puma listens on (default: 3000)
TARGET_PORT=3000

# Domain for ACME/Let's Encrypt TLS certificate acquisition
SSL_DOMAIN=myapp.example.com

# Maximum size of a cached response in bytes (default: 1MB)
MAX_CACHE_ITEM_SIZE=1048576

# Request timeout in seconds (default: 60)
REQUEST_TIMEOUT=60

For a Kamal 2 deployment, set these in your deploy.yml env block or in an .env file that Kamal reads at deploy time:

# config/deploy.yml
env:
  clear:
    HTTP_PORT: "80"
    HTTPS_PORT: "443"
    TARGET_PORT: "3000"
  secret:
    - SSL_DOMAIN
    - RAILS_MASTER_KEY

The SSL_DOMAIN belongs in secrets because it leaks your production domain name through your version control history if you commit it in plaintext. RAILS_MASTER_KEY is required for decrypting Rails credentials at runtime.

TLS with ACME: Zero-Config HTTPS

Set SSL_DOMAIN and Thruster handles the rest. On first boot, it contacts Let’s Encrypt’s ACME endpoint, completes the HTTP-01 challenge on port 80, fetches a certificate, and starts accepting HTTPS traffic on port 443. On subsequent boots, it loads the cached certificate and renews it automatically before expiry.

The certificates are stored inside the container by default. That works when your container has a persistent volume or when you are running a single-node deployment and container restarts are rare. For multi-node or ephemeral container environments, you want persistent certificate storage:

# In your Docker Compose or Kubernetes volume config:
volumes:
  - thruster_certs:/rails/storage/thruster

# Or with Kamal 2:
volumes:
  - /data/myapp/thruster:/rails/storage/thruster

The path storage/thruster is where Thruster writes its certificate cache by default. Mount a host directory there and certificates survive container rebuilds.

One caveat: ACME HTTP-01 challenges require that port 80 on your server is reachable from the public internet with the correct DNS A record pointing at it. If your setup involves a load balancer that terminates TLS before the server — AWS ALB, Cloudflare proxied mode, a Kamal Proxy in front — you want to disable Thruster’s TLS and let the upstream handle it. Leave SSL_DOMAIN unset, and Thruster runs HTTP only, proxying to Puma without attempting ACME.

Thruster with Kamal 2: Two Deployment Patterns

Kamal 2 ships its own proxy — Kamal Proxy — which handles container routing, zero-downtime deploys, and optionally TLS. There are two sensible configurations when running Kamal 2:

Pattern 1: Kamal Proxy handles TLS, Thruster handles HTTP/2 and compression

# config/deploy.yml
proxy:
  ssl: true
  host: myapp.example.com
  # Kamal Proxy fetches the cert and terminates TLS
  # It forwards HTTP/1.1 to each container on the internal port

In this pattern, Kamal Proxy sits at port 443 and handles Let’s Encrypt certificates, load balancing between containers, and health-checked rolling deploys. Inside each container, Thruster is listening on HTTP (port 80, no TLS) and still gives you compression, static asset caching, and HTTP/2 for the browser-to-Kamal-Proxy leg (Kamal Proxy itself speaks HTTP/2 to clients).

Do not set SSL_DOMAIN in the container env here — Thruster should not attempt ACME challenges when a proxy upstream is already handling TLS.

Pattern 2: Thruster handles TLS directly (single server, no Kamal Proxy)

# config/deploy.yml
proxy:
  ssl: false  # or omit the proxy block entirely

Thruster listens on 443, handles ACME itself, and there is no Kamal Proxy. This works well for single-server deployments — a VPS, a dedicated machine — where zero-downtime deploys are handled by Kamal’s container swap rather than a proxy layer. You set SSL_DOMAIN in the container environment and Thruster owns the entire HTTPS stack.

For the vast majority of my single-VPS client deployments that were previously Nginx + Let’s Encrypt + Certbot, Pattern 2 has been a direct replacement with less maintenance surface. The two files that disappear are nginx.conf and the Certbot systemd timer.

Compression: Brotli and Gzip

Thruster compresses text responses automatically. The algorithm selection is based on the Accept-Encoding header: Brotli (br) if the client supports it, gzip otherwise. Binary responses — images, fonts, video — are passed through uncompressed regardless of content type, since they are already compressed at the format level.

The compression threshold defaults to responses above 1 KB. Responses below that size cost more in CPU overhead than they save in transfer time. You can tune this if your application serves a lot of small JSON payloads that hover around the threshold:

# Only compress responses above 4KB
MIN_COMPRESS_RESPONSE_SIZE=4096

For a typical Rails application serving Turbo Drive page navigations (HTML responses in the 10–100 KB range) and JSON API responses, Brotli compression reduces transfer size by 60–80% compared to uncompressed, and 15–25% compared to gzip. The compression happens in the Go process, which is faster at this than Ruby, without blocking Puma threads.

The practical effect: for a Rails app with a 400ms average response time, Thruster’s Brotli adds roughly 2–5ms of compression latency for a 50 KB HTML response. The transfer time savings on a mobile connection more than compensate.

Static Asset Caching

When Puma returns a response with Cache-Control: public, max-age=..., Thruster stores it in its in-process cache. The next request for the same URL is served from memory without touching Puma. This is particularly useful for fingerprinted assets — your application-abc123.js file has an infinite max-age, so after the first request it never reaches Puma again for the lifetime of that container.

The default maximum cache item size is 1 MB. Adjust it based on your largest assets:

# Cache items up to 5MB (useful if you have large font bundles)
MAX_CACHE_ITEM_SIZE=5242880

The cache is not shared between containers. Each container instance caches independently. That is not a problem for immutable fingerprinted assets — every container caches the same content — but dynamic responses with Cache-Control: public will have a cold cache on each deploy. Design your cache headers accordingly.

For the Propshaft asset pipeline setup, all fingerprinted assets already carry Cache-Control: public, max-age=31536000. Thruster caches them on first hit. Combined with CloudFront in front of everything, this means fingerprinted assets are served from CDN on subsequent hits and never reach the container at all. Thruster’s cache matters most for the first request that misses CDN on a cache invalidation.

What Thruster Does Not Replace

Thruster is not Nginx. If you need any of the following, you still want Nginx or a purpose-built reverse proxy:

WebSocket upgrades for Action Cable. Thruster proxies standard HTTP/2 connections. If you are using Action Cable with WebSockets directly (rather than via Solid Cable over SSE), verify that your Thruster version handles the upgrade correctly. In most cases this works, but test it explicitly before shipping.

Rate limiting. Thruster has no rate limiting primitives. For request rate limiting at the proxy layer, you need Nginx with limit_req_zone or a CDN-level WAF rule. Rails-level rate limiting with Rack::Attack handles it at the application layer, which is fine for most applications but fires after the request reaches Puma.

Complex routing between multiple backends. If your infrastructure routes /api/ to one service and / to another, Nginx’s location blocks are the right tool. Thruster proxies everything to a single Puma process.

IP allowlisting and geo-blocking. Nginx can reject requests before they reach your application. Thruster passes everything through to Puma. If you need request filtering at the proxy layer, keep Nginx in the stack.

Serving files from disk outside of Rails. Thruster caches Puma responses. It does not serve files directly from the filesystem the way nginx root /srv/myapp/public does. For Kamal 2 deployments where all file serving runs through Rails and Propshaft, this is not a limitation. If you are serving large binary files directly from disk, Nginx with sendfile on will still outperform this setup.

Tuning for Production

The two Thruster settings that matter most in production are the request timeout and the target port.

# Thruster waits this many seconds for Puma to return a response
# Default: 60 seconds — too long for most request SLOs
# If your background jobs are queued to ActiveJob, 15s is usually generous
REQUEST_TIMEOUT=15

# The port your Puma process listens on
# Must match config/puma.rb or the PORT env var Puma uses
TARGET_PORT=3000

If you have long-running requests — PDF generation, large CSV exports, batch processing — increase the timeout or, better, move those operations to background jobs with Solid Queue and serve the export via a polling pattern or Action Cable notification. Holding a Puma thread for 60 seconds for a file export blocks all other concurrent requests on that worker.

For Puma tuning — workers, threads, memory limits — that is a separate concern from Thruster. I covered it in the Puma tuning guide. Thruster and Puma are independently tunable. The Thruster timeout should be slightly above your p99 Puma response time. If your p99 is 3 seconds, a 10-second Thruster timeout gives you a generous buffer without holding connections open indefinitely for genuinely stuck requests.

Health Checks with Thruster

Rails 8 adds a built-in health check endpoint at /up. Thruster passes this through to Puma cleanly. Kamal 2 polls it during deploys to verify the new container is healthy before switching traffic.

If you are running Thruster without Kamal 2 and need health checks for an external monitor:

# config/routes.rb
# Rails 8 adds this automatically, but you can customize it:
get "/up", to: lambda { |_env|
  [200, { "Content-Type" => "text/plain" }, ["OK"]]
}

Thruster does not add health check logic itself — it passes /up to Puma. If Puma is not running, Thruster returns a 502. Your monitor treats a 502 as unhealthy. This is the correct behavior: if Puma is down, the container is down.

Migrating from Nginx to Thruster

The migration is straightforward if your Nginx configuration is doing standard Rails proxy work. A typical minimal Rails Nginx config:

upstream puma {
  server unix:///tmp/myapp.sock;
}

server {
  listen 443 ssl http2;
  server_name myapp.example.com;

  ssl_certificate     /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;

  gzip on;
  gzip_types text/html application/javascript text/css application/json;

  location /assets {
    expires max;
    add_header Cache-Control public;
  }

  location / {
    proxy_pass http://puma;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 60;
  }
}

Every one of these directives has an equivalent in Thruster:

  • http2 → built in, always on
  • SSL certificate via Certbot → replaced by SSL_DOMAIN ACME automation
  • gzip → built in, Brotli by default
  • /assets long-lived caching → handled by Propshaft headers + Thruster cache
  • proxy_set_header X-Forwarded-For → built in
  • proxy_read_timeout 60REQUEST_TIMEOUT=60

What you lose: the proxy_set_header X-Forwarded-Proto forwarding needs to be verified. Rails reads config.force_ssl and X-Forwarded-Proto to determine whether to redirect HTTP to HTTPS. With Thruster handling TLS, requests hitting Puma are HTTP internally. Set this in your Rails config:

# config/environments/production.rb
config.force_ssl = true

# Tell Rails to trust the X-Forwarded-Proto header from Thruster
config.action_dispatch.trusted_proxies = [
  "127.0.0.1",
  "::1",
  IPAddr.new("10.0.0.0/8"),
  IPAddr.new("172.16.0.0/12"),
  IPAddr.new("192.168.0.0/16")
]

Thruster sets X-Forwarded-Proto: https on requests that arrived over HTTPS. Rails reads it and treats those requests as secure, so request.ssl? returns true and url_for generates https:// URLs correctly.

Troubleshooting Common Issues

Container starts, but HTTPS returns 502. The ACME challenge failed. Check that your DNS A record resolves to the server’s public IP and port 80 is open in your firewall. The ACME HTTP-01 challenge goes to port 80 first, even for HTTPS certificate issuance. Check container logs for acme: prefixed log lines from Thruster.

Static assets returning 304 when they should be 200. The asset fingerprints match an existing browser cache entry. This is correct behavior — your browser cached the previous asset URL and is sending If-None-Match. Thruster serves the 304 without hitting Puma. If asset content changed but the fingerprint did not (this happens if you bypass Propshaft with hand-edited files), clear the browser cache.

X-Forwarded-For showing the wrong IP. Your server is behind an upstream proxy (Kamal Proxy, CloudFront, a load balancer) that is already setting X-Forwarded-For. Thruster appends the connecting IP, resulting in a header with multiple IPs. Configure your trusted_proxies in Rails to include the upstream proxy IP range, and request.remote_ip will correctly extract the original client IP from the leftmost untrusted entry.

Thruster exits immediately on startup. The most common cause is a port conflict — something else is listening on port 80 or 443. On a fresh VPS this is usually a default Apache or Nginx installation. Disable it with systemctl stop nginx && systemctl disable nginx before starting your Thruster container.

Large file uploads timing out. Thruster’s REQUEST_TIMEOUT includes upload time. A 200 MB CSV upload over a slow connection can easily exceed the default 60 seconds. Either increase the timeout for upload endpoints or — better — switch to direct-to-S3 uploads. I covered the setup in the Active Storage S3 direct upload post, which eliminates upload timeouts entirely by bypassing your server for the upload itself.

Is Thruster Right for Your Setup?

After running Rails Thruster in production across six client applications since Rails 8 launched, my heuristic is simple:

  • Single VPS deployment with Kamal 2 → Thruster with ACME. Drop Nginx, drop Certbot. Two fewer things to maintain.
  • Multi-server with Kamal Proxy → Thruster inside each container for HTTP/2 and compression, Kamal Proxy for TLS and routing. Both in the stack.
  • AWS ALB or CloudFront in front → Thruster without SSL_DOMAIN (HTTP only). ALB handles TLS, CloudFront handles caching. Thruster still gives you HTTP/2 between ALB and the container if your ALB target group uses HTTP/2.
  • Complex routing requirements (multiple backends, geo-fencing, IP allowlisting) → Keep Nginx. Thruster does not have these primitives and is not trying to.

The productivity gain is real for the simple case. The Nginx config I deleted from the first client deployment in early 2025 had accumulated four years of # TODO: understand why this is here comments. The Thruster replacement is ten environment variables and no comments required.

FAQ

What is Rails Thruster and why did Rails 8 introduce it?

Rails Thruster is a lightweight Go-based HTTP/2 proxy that ships as part of the Rails 8 ecosystem. It wraps Puma, handles TLS certificate acquisition via ACME (Let’s Encrypt), compresses responses with Brotli and gzip, and caches static assets in memory. Rails 8 introduced it to reduce deployment complexity — previously, a standard production Rails deployment required Nginx or Apache as a reverse proxy, Certbot for TLS certificate management, and separate configuration for compression and caching. Thruster consolidates all of these into the bin/thrust binstub that wraps your Puma command in the default Rails 8 Dockerfile.

Does Rails Thruster replace Nginx completely?

For a standard single-application deployment, Rails Thruster replaces the reverse proxy, TLS terminator, gzip layer, and static asset cache that Nginx was providing. It does not replace Nginx’s rate limiting directives, multi-backend routing, IP filtering, or sendfile-based direct file serving. If your Nginx configuration only contained proxy_pass, ssl_certificate, and gzip on, Thruster is a complete replacement. If it contained limit_req_zone, multiple location blocks routing to different backends, or alias directives serving files directly from disk, keep Nginx.

How does Rails Thruster handle TLS certificate renewal?

Thruster uses ACME (the same protocol Certbot uses) to fetch certificates from Let’s Encrypt. It handles the HTTP-01 challenge on port 80, stores the certificate locally, and renews it automatically before expiry — no cron job, no certbot renew, no certificate expiry alerts. Certificates are stored in storage/thruster/ inside the container. Mount a host volume at that path to preserve certificates across container rebuilds. Set SSL_DOMAIN to your production domain and Thruster does the rest on first boot.

Can I use Rails Thruster with Kamal 2?

Yes. There are two patterns. If you use Kamal Proxy for TLS termination and load balancing (the default for multi-server Kamal 2 setups), run Thruster inside each container without SSL_DOMAIN — it will proxy HTTP traffic and add compression without attempting ACME. If you are on a single server without Kamal Proxy, set SSL_DOMAIN in your container environment and Thruster handles TLS directly. In both cases, the CMD in your Dockerfile is ./bin/thrust bundle exec puma -C config/puma.rb — the difference is only in which environment variables are set.

After nineteen years of maintaining Nginx configs with comments that outlasted the engineers who wrote them, Rails Thruster is a genuine improvement for the standard single-application deployment. TTB Software helps Rails teams simplify their production infrastructure and deploy with confidence. If your deployment pipeline has layers of configuration nobody fully understands anymore, we can help untangle it.

#rails-thruster #rails-8-thruster-production #thruster-replace-nginx-rails #rails-http2-proxy-production #kamal-2-thruster-configuration #rails-tls-acme-lets-encrypt

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software