Skip to content

feat: add nginx reverse proxy for Plausible analytics#2595

Open
mroderick wants to merge 1 commit intocodebar:masterfrom
mroderick:feature/nginx-plausible-proxy-clean
Open

feat: add nginx reverse proxy for Plausible analytics#2595
mroderick wants to merge 1 commit intocodebar:masterfrom
mroderick:feature/nginx-plausible-proxy-clean

Conversation

@mroderick
Copy link
Copy Markdown
Collaborator

@mroderick mroderick commented Apr 25, 2026

Summary

This PR implements the proposal from #2580 to run nginx as a reverse proxy in front of Rails on Heroku. This achieves two primary goals:

  1. Plausible Proxy — Serve Plausible analytics through our domain, bypassing adblockers
  2. Security Headers — Add security headers at nginx level before requests reach Rails

Changes

Infrastructure

  • Add heroku-community/nginx buildpack configuration (config/nginx.conf.erb)
  • Update Procfile to use bin/start-nginx wrapper
  • Configure Puma to bind to Unix socket on Heroku (config/puma.rb)

Analytics

  • Update Plausible snippet to use proxied endpoints (/js/script.js, /api/event)
  • Pass through Cache-Control headers from Plausible

Proxy Caching

  • Cache Plausible script in /dev/shm (memory-backed, faster than disk)
  • X-Cache header shows HIT/MISS for debugging
  • 1m max cache size (single ~30KB JS file)
  • Note: Cache is ephemeral — cleared on dyno restart (same as /tmp)

Security Headers

  • X-Frame-Options: SAMEORIGIN
  • X-Content-Type-Options: nosniff
  • X-XSS-Protection: 1; mode=block
  • Referrer-Policy: strict-origin-when-cross-origin

Implementation Notes

What we learned (deviations from initial plan)

  1. /dev/shm for proxy_cache — Initial plan suggested /tmp, but /dev/shm is a memory-backed tmpfs which is faster than disk-backed /tmp. Both are ephemeral and cleared on dyno restart.

  2. Detection method — Initial plan used ENV["DYNO"], but this isn't set on all dyno types. Used File.exist?("config/nginx.conf.erb") instead to detect when nginx config is present.

  3. Nginx 'set' directive — Initially placed in http block (lines 23-24), but nginx only allows 'set' inside server or location blocks. Had to move into server context.

  4. DNS resolver — Heroku handles DNS for the main app, but nginx needs explicit resolver for upstream proxy_pass domains (plausible.io). Added Quad9 (9.9.9.9) as recommended by Plausible.

Key technical discoveries

  • heroku-buildpack-nginx waits for /tmp/app-initialized file before starting nginx. Puma must create this file after binding to socket.
  • nginx 'set' directive only works in server/location contexts, not http block
  • Puma must bind to unix socket AND signal readiness for nginx to connect
  • Quad9 DNS (9.9.9.9) is recommended by Plausible for upstream resolution

Security filters not included

The initial proposal included security filters (blocking malicious UAs, attack paths). These were removed to simplify the implementation and reduce maintenance burden. Security headers provide most of the benefit with minimal complexity.

Deployment

One-time setup: Add nginx buildpack

The nginx buildpack must be added BEFORE the Ruby buildpack so it runs AFTER to serve the final response:

# Check current buildpack order
heroku buildpacks --app codebar-staging

# Add nginx buildpack (position 1 = first, runs last)
heroku buildpacks:add --index 1 heroku-community/nginx --app codebar-staging
heroku buildpacks:add --index 1 heroku-community/nginx --app codebar-production

# Verify order should be:
# 1. heroku-community/nginx  (runs last, serves response)
# 2. heroku/ruby         (runs first, builds app)

Expected output after adding:

=== codebar-staging Buildpack URLs
1. https://github.com/heroku/heroku-buildpack-nginx
2. https://github.com/heroku/heroku-buildpack-ruby

Deploy

# Deploy to staging first
git push staging feature/nginx-plausible-proxy-clean:main

# Test proxy and caching (see Testing section below)

# Deploy to production after confirming staging is working
git push production feature/nginx-plausible-proxy-clean:main

Testing Checklist

Plausible Proxy:

  • /js/script.js returns Plausible script with correct content-type (200 OK)
  • /api/event accepts POST and forwards to Plausible ({ok})
  • First request returns X-Cache: MISS
  • Second request returns X-Cache: HIT

Security Headers:

  • X-Frame-Options: SAMEORIGIN
  • X-Content-Type-Options: nosniff
  • X-XSS-Protection: 1; mode=block
  • Referrer-Policy: strict-origin-when-cross-origin

Rails Functionality:

  • Homepage loads correctly (200 OK)
  • Login redirect works (302)
  • Routes respond correctly

Rollback

If issues arise:

# Remove nginx buildpack
heroku buildpacks:remove heroku-community/nginx --app codebar-production
heroku buildpacks:remove heroku-community/nginx --app codebar-staging

# Rollback to previous release
heroku releases:rollback --app codebar-production
heroku releases:rollback --app codebar-staging

References

@mroderick mroderick force-pushed the feature/nginx-plausible-proxy-clean branch from 50f96fa to ae07616 Compare April 25, 2026 13:28
@mroderick mroderick requested a review from till April 25, 2026 13:32
@mroderick mroderick marked this pull request as ready for review April 25, 2026 13:56
Comment thread config/nginx.conf.erb
@mroderick mroderick force-pushed the feature/nginx-plausible-proxy-clean branch from 00bffd1 to 2f6a70c Compare April 26, 2026 09:46
@mroderick mroderick requested a review from till April 26, 2026 10:03
@mroderick mroderick force-pushed the feature/nginx-plausible-proxy-clean branch 2 times, most recently from 613c947 to 2d73a9a Compare April 26, 2026 10:10
- Proxy Plausible script and event API through nginx to bypass adblockers
- Add DNS resolver (Quad9 9.9.9.9) for upstream domain resolution
- Add proxy_cache in /dev/shm for Plausible script (5m TTL, 100m max)
- Add security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy)
- Configure Puma to bind to Unix socket when nginx config exists
- Signal nginx buildpack readiness via /tmp/app-initialized file

## Lessons learned

Deviations from initial plan:
- Used /dev/shm (tmpfs) for proxy_cache instead of /tmp (limited space)
- Used ENV detection for nginx config file presence, not DYNO var (not available on all dynos)
- Moved nginx 'set' directives inside server block (required by nginx)
- Had to add DNS resolver manually (Heroku's resolver not automatically available in proxy_pass)

Key technical discoveries:
- heroku-buildpack-nginx waits for /tmp/app-initialized file before starting nginx
- nginx 'set' directive only works in server/location contexts, not http block
- Puma must bind to unix socket AND signal readiness for nginx to connect
- proxy_cache in /dev/shm persists across dyno restarts (vs /tmp which doesn't)

See: github.com/codebar/discussions/2580
@mroderick mroderick force-pushed the feature/nginx-plausible-proxy-clean branch from 891a726 to df5e4e4 Compare April 26, 2026 16:18
@mroderick mroderick changed the title Add nginx reverse proxy for Plausible analytics and security filtering feat: add nginx reverse proxy for Plausible analytics Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants