Skip to content

Commit 50f96fa

Browse files
committed
docs: add design specification for nginx Plausible proxy
1 parent 5d420b9 commit 50f96fa

1 file changed

Lines changed: 293 additions & 0 deletions

File tree

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
# Design: Nginx for Plausible Proxy + Security Filtering
2+
3+
**Date:** 2026-04-25
4+
**Author:** OpenCode (with user collaboration)
5+
**Status:** Approved for Implementation
6+
**Branch:** feature/nginx-plausible-proxy
7+
8+
---
9+
10+
## Problem Statement
11+
12+
Plausible analytics (currently loaded from `plausible.io/js/pa-PFruVsE_br97UUCRXE_6f.js` in `application.html.haml`) gets blocked by adblockers. Studies show 6-60% of users run adblockers, meaning we lose significant visibility into site usage.
13+
14+
## Solution Overview
15+
16+
Run **heroku/heroku-buildpack-nginx** as a reverse proxy in front of Rails. This enables:
17+
18+
1. **Plausible proxy** — Serve Plausible script and API endpoints through our domain, bypassing adblockers
19+
2. **Security filtering** — Block obvious bad actors and common attack patterns before they reach Rails
20+
21+
---
22+
23+
## Architecture
24+
25+
```
26+
┌─────────────────┐ ┌─────────────┐ ┌─────────────┐
27+
│ Client │────▶│ Nginx │────▶│ Puma │
28+
│ (Browser) │ │ (Heroku │ │ (Rails) │
29+
│ │ │ buildpack)│ │ │
30+
└─────────────────┘ └─────────────┘ └─────────────┘
31+
32+
├──▶ /js/script.js ──▶ Plausible CDN
33+
├──▶ /api/event ────▶ Plausible API
34+
└──▶ 444 for bad actors (connection closed)
35+
```
36+
37+
Nginx listens on `$PORT` (Heroku requirement), proxies valid requests to Puma via UNIX socket. Plausible routes are reverse-proxied to `plausible.io`. Malicious traffic is dropped with HTTP 444 (nginx closes connection without response).
38+
39+
---
40+
41+
## Configuration
42+
43+
### 1. Heroku Buildpack
44+
45+
Add nginx buildpack at index 1 (before Ruby buildpack):
46+
47+
```bash
48+
heroku buildpacks:add --index 1 heroku-community/nginx
49+
```
50+
51+
**Rationale:** Buildpacks run in order. Nginx must be installed before the app starts.
52+
53+
### 2. Nginx Configuration (`config/nginx.conf.erb`)
54+
55+
Heroku's nginx buildpack uses ERB templating for dynamic values:
56+
57+
```nginx
58+
daemon off;
59+
worker_processes auto;
60+
61+
events {
62+
worker_connections 1024;
63+
}
64+
65+
http {
66+
charset utf-8;
67+
server_tokens off;
68+
69+
# Logs to stdout/stderr for Heroku
70+
log_format custom '$http_x_forwarded_for - $remote_user [$time_local] '
71+
'"$request" $status $body_bytes_sent '
72+
'"$http_referer" "$http_user_agent"';
73+
access_log /dev/stdout custom;
74+
error_log /dev/stderr;
75+
76+
# Plausible endpoints
77+
set $plausible_script_url https://plausible.io/js/pa-PFruVsE_br97UUCRXE_6f.js;
78+
set $plausible_event_url https://plausible.io/api/event;
79+
80+
# Proxy settings
81+
proxy_http_version 1.1;
82+
proxy_buffering on;
83+
84+
upstream app_server {
85+
server unix:/tmp/nginx.socket fail_timeout=0;
86+
}
87+
88+
server {
89+
listen <%= ENV["PORT"] %>;
90+
server_name _;
91+
keepalive_timeout 5;
92+
client_max_body_size 10m;
93+
94+
# Security: Block known bad user agents
95+
if ($http_user_agent ~* (nikto|sqlmap|nessus|acunetix|masscan|zgrab|gobuster|dirbuster)) {
96+
return 444;
97+
}
98+
99+
# Security: Block common attack paths
100+
location ~ ^/(\.env|\.git|\.htaccess|\.htpasswd|config\.js|wp-admin|wp-login|xmlrpc\.php|administrator|phpmyadmin|shell|cmd|backdoor|cgi-bin) {
101+
return 444;
102+
}
103+
104+
# Plausible: Proxy script.js
105+
location = /js/script.js {
106+
proxy_pass $plausible_script_url;
107+
proxy_set_header Host plausible.io;
108+
proxy_pass_header Cache-Control;
109+
proxy_buffering on;
110+
}
111+
112+
# Plausible: Proxy event API
113+
location = /api/event {
114+
proxy_pass $plausible_event_url;
115+
proxy_set_header Host plausible.io;
116+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
117+
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
118+
proxy_set_header X-Forwarded-Host $host;
119+
proxy_buffering on;
120+
}
121+
122+
# Rails: All other requests
123+
location / {
124+
proxy_pass http://app_server;
125+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
126+
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
127+
proxy_set_header Host $http_host;
128+
proxy_redirect off;
129+
}
130+
}
131+
}
132+
```
133+
134+
**Key decisions:**
135+
- **UNIX socket** (`/tmp/nginx.socket`) for nginx → Puma communication (faster than TCP, no port conflicts)
136+
- **HTTP 444** for blocked requests — nginx closes connection immediately, no response body (saves bandwidth, frustrates attackers)
137+
- **`proxy_pass_header Cache-Control`** — Passes through Plausible's cache headers without modification
138+
- **`$http_x_forwarded_proto`** — Uses Heroku's forwarded proto header instead of `$scheme` (which would be incorrect behind Heroku's SSL termination)
139+
- **`server_tokens off`** — Hides nginx version from error pages
140+
141+
### 3. Procfile Update
142+
143+
```
144+
release: bundle exec rake db:migrate
145+
web: bin/start-nginx bundle exec puma -C config/puma.rb
146+
```
147+
148+
The `bin/start-nginx` wrapper (provided by the buildpack):
149+
1. Starts nginx
150+
2. Waits for UNIX socket to be created
151+
3. Starts the specified command (Puma)
152+
4. Manages process lifecycle
153+
154+
### 4. Application Layout Update (`app/views/layouts/application.html.haml`)
155+
156+
Replace lines 33-37:
157+
158+
```haml
159+
<!-- Privacy-friendly analytics by Plausible -->
160+
%script{ async: true, src: '/js/script.js' }
161+
%script
162+
window.plausible = window.plausible || function() { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function(i) { plausible.o = i || {} };
163+
plausible.init({ endpoint: '/api/event' })
164+
```
165+
166+
---
167+
168+
## Security Filters
169+
170+
### Blocked User Agents
171+
Pattern match against known scanning tools:
172+
- `nikto` — Web vulnerability scanner
173+
- `sqlmap` — SQL injection tool
174+
- `nessus` — Vulnerability scanner
175+
- `acunetix` — Web app scanner
176+
- `masscan`, `zgrab` — Port scanners
177+
- `gobuster`, `dirbuster` — Directory brute-forcers
178+
179+
### Blocked Paths
180+
Common attack targets that don't exist in this Rails app:
181+
- WordPress paths: `/wp-admin`, `/wp-login`, `/xmlrpc.php`
182+
- Config files: `/.env`, `/.git/`, `/config.js`, `/.htaccess`
183+
- Admin tools: `/administrator`, `/phpmyadmin`
184+
- Shell attempts: `/shell`, `/cmd`, `/backdoor`, `/cgi-bin`
185+
186+
**Rationale:** These are automated attacks. Returning 444 immediately closes the connection without wasting Rails resources or revealing any information.
187+
188+
---
189+
190+
## Testing Strategy
191+
192+
### Manual Testing Checklist
193+
194+
**Plausible Proxy:**
195+
- [ ] `curl -I https://<staging>/js/script.js` returns 200 with JavaScript content-type
196+
- [ ] `curl -I https://<staging>/api/event` accepts POST requests
197+
- [ ] Plausible dashboard shows events from staging
198+
- [ ] Response headers include `Cache-Control` from Plausible
199+
200+
**Security Filters:**
201+
- [ ] `curl -A "Nikto" https://<staging>/` returns 444 (no response)
202+
- [ ] `curl https://<staging>/.env` returns 444
203+
- [ ] `curl https://<staging>/wp-admin` returns 444
204+
205+
**Rails Functionality:**
206+
- [ ] Homepage loads correctly
207+
- [ ] Login works
208+
- [ ] Workshop pages load
209+
- [ ] Admin area accessible (with auth)
210+
- [ ] Assets load (CSS, JS, images)
211+
- [ ] Forms submit correctly
212+
213+
**Error Scenarios:**
214+
- [ ] 404 pages still work
215+
- [ ] 500 errors still render error page
216+
217+
### Staging-First Deployment
218+
219+
1. Deploy branch to staging
220+
2. Run full manual checklist
221+
3. Monitor for 24 hours
222+
4. Deploy to production
223+
224+
---
225+
226+
## Deployment Commands
227+
228+
```bash
229+
# 1. Add buildpack (production)
230+
heroku buildpacks:add --index 1 heroku-community/nginx --app codebar-production
231+
232+
# 2. Add buildpack (staging)
233+
heroku buildpacks:add --index 1 heroku-community/nginx --app codebar-staging
234+
235+
# 3. Deploy branch to staging
236+
git push staging feature/nginx-plausible-proxy:main
237+
238+
# 4. After testing, deploy to production
239+
git push production feature/nginx-plausible-proxy:main
240+
```
241+
242+
---
243+
244+
## Rollback Plan
245+
246+
If issues arise:
247+
248+
```bash
249+
# Quick rollback: Remove nginx from Procfile and redeploy
250+
git checkout master -- Procfile
251+
git commit -m "Rollback: Remove nginx proxy"
252+
git push production main
253+
254+
# Or: Remove buildpack and revert to previous release
255+
heroku buildpacks:remove heroku-community/nginx --app codebar-production
256+
heroku rollback --app codebar-production
257+
```
258+
259+
---
260+
261+
## Future Enhancements (Out of Scope)
262+
263+
These are documented but not implemented now:
264+
265+
1. **Rate limiting**`limit_req_zone` for login endpoints, API abuse prevention
266+
2. **Caching**`proxy_cache` for Plausible script with 5-minute TTL
267+
3. **Additional security headers** — HSTS, CSP headers at nginx level
268+
4. **Geographic filtering** — Block high-risk countries if needed
269+
270+
---
271+
272+
## References
273+
274+
- [Heroku Nginx Buildpack](https://github.com/heroku/heroku-buildpack-nginx)
275+
- [Plausible Proxy Overview](https://plausible.io/docs/proxy/introduction)
276+
- [Plausible Proxy Guide: Nginx](https://plausible.io/docs/proxy/guides/nginx)
277+
- [Nginx HTTP Proxy Module](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
278+
279+
---
280+
281+
## Decision Log
282+
283+
| Decision | Rationale |
284+
|----------|-----------|
285+
| Skip nginx caching | Simpler config, can add later if needed |
286+
| No rate limiting yet | Not requested, easy to add later |
287+
| Pass through Cache-Control | Respect Plausible's intended caching |
288+
| Use 444 for blocked requests | Close connection immediately, save resources |
289+
| UNIX socket for Puma | Faster than TCP, no port management |
290+
| Manual testing only | Infrastructure change, hard to automate meaningfully |
291+
| Use `$http_x_forwarded_proto` | Heroku terminates SSL; `$scheme` would incorrectly report 'http' |
292+
| Socket path `/tmp/nginx.socket` | Heroku buildpack convention; Puma binds when `DYNO` env var present |
293+
| `server_tokens off` | Security: hide nginx version from error pages |

0 commit comments

Comments
 (0)