Reverse proxy
Why do I need a reverse proxy?
Section titled “Why do I need a reverse proxy?”When you deploy Dulak, the app listens on a port (default 4000) and
speaks plain HTTP — no encryption. If a user visits
http://your-server-ip:4000, their browser connects directly to your
server and everything (passwords, session cookies, form data) travels
over the internet in plaintext. Anyone on the path can read it.
You need HTTPS (https://your-domain.com — the lock icon). That
requires three things the app doesn’t do itself:
- TLS certificate — proves your server is really
your-domain.comand encrypts traffic. Browsers refuse to show the lock icon without it. - Listen on port 443 (HTTPS) instead of
4000— browsers default to 443 forhttps://URLs. Port 4000 is non-standard; users would have to type:4000manually. - A way to get the certificate and renew it — certs expire every 90 days (Let’s Encrypt) or are managed for you (Cloudflare).
A reverse proxy sits between the internet and your app, handles all three, and forwards traffic to your app on port 4000:
User's browser │ HTTPS (port 443, encrypted) ▼Reverse proxy (TLS cert, port 443) │ HTTP (port 4000, local only) ▼Dulak (Bun, port 4000)The app never sees the internet directly — the proxy terminates TLS, then forwards the decrypted request locally. Your app stays simple (no cert management, no TLS config), and the proxy handles the parts it’s good at.
Which one? If your domain is on Cloudflare, use that — it’s the proxy, nothing to install. Otherwise, Caddy (auto-TLS via Let’s Encrypt) or Nginx + Certbot both work. Details below.
Dulak listens on a single port (default 4000). It does not terminate
TLS — that is the proxy’s job. Whether you run Docker or bare-metal, the
proxy sits in front and forwards to 127.0.0.1:4000.
Internet → [Cloudflare edge | Caddy | Nginx] → 127.0.0.1:4000 └→ Dulak (Bun)Pick one option. All three work with both Docker and bare-metal VPS deployments.
→ See also: Nginx is dead. Cloudflare killed it — why Cloudflare + Bun.serve covers all five jobs Nginx does, and A1 (Proxied DNS + Origin Rule) is the recommended default.
Option A — Cloudflare (recommended, nothing to install)
Section titled “Option A — Cloudflare (recommended, nothing to install)”Your domain is on Cloudflare, so the edge handles TLS and proxying; the
server only runs Bun. Two routes are available — A1 (Proxied DNS +
Origin Rule) is recommended as the default: no extra software, no
open ports beyond what Cloudflare uses, and the Origin Rule ensures the
app sees the correct Host header for CSRF and OAuth. A2 (Tunnel) is
the alternative when you want zero inbound ports at all.
A1. Proxied DNS + origin rule (no extra software)
Section titled “A1. Proxied DNS + origin rule (no extra software)”Cloudflare proxies traffic to your server and handles TLS. No software to install on the server — just DNS and dashboard settings.
Step 1 — DNS record:
- Cloudflare Dashboard → DNS → Records.
- Add an A record: name
your-domain.com(or@), content = your server IP, proxy status Proxied (orange cloud).- “Proxied” means traffic goes through Cloudflare’s edge (TLS, DDoS protection, caching). “DNS only” (grey cloud) just resolves the IP and exposes your server directly — don’t use that.
- (Optional) Add a
wwwCNAME pointing toyour-domain.com, also proxied.
Step 2 — SSL/TLS mode:
Dashboard → SSL/TLS → Overview → set mode to Flexible:
- Flexible (recommended) — Cloudflare terminates TLS at the edge, talks HTTP to your origin on port 4000. No cert needed on the server. This is the simplest setup and works well for boilerplate apps like Dulak. Safe because the firewall only allows Cloudflare IPs (see Firewall below).
- Full (optional) — edge→origin over HTTPS. You need a cert on the origin (Cloudflare Origin CA cert, free from Dashboard → SSL/TLS → Origin Server → Create Certificate). More secure for sensitive apps, but more setup. Skip this unless you have a specific reason.
Multiple domains? Flexible mode is per-zone (per-domain) in Cloudflare, so each domain you point at the same server gets its own TLS at the edge — no extra cert management. Just add another A record
- Origin Rule per domain. The same Dulak instance serves all of them on port 4000.
Step 3 — Origin Rule (set this up now, don’t wait for it to break):
Cloudflare normally passes the original Host header through, but some
configurations (CNAME setups, partial proxy, Cloudflare Workers) can
rewrite it. Dulak’s CSRF check compares the Origin header against
Host, and OAuth redirect URIs depend on the correct host — so a
rewritten Host header breaks both silently. Set up the Origin Rule
upfront:
- Dashboard → Rules → Origin Rules → Create rule.
- Name:
Rewrite host for dulak. - If incoming requests match:
Hostname equals your-domain.com. - Rewrite Host header to:
your-domain.com. - Deploy.
This guarantees the app sees your-domain.com as the Host on every
request — CSRF passes, OAuth callbacks resolve correctly, and redirect
URLs point to the right domain. It’s one rule that prevents a class of
bugs that are hard to debug because they only manifest on specific
Cloudflare configurations.
Step 4 — Firewall: restrict port 4000 to Cloudflare IPs only (see Firewall below) so nobody can bypass the edge and hit your origin directly.
A2. Cloudflare Tunnel (alternative — zero open ports)
Section titled “A2. Cloudflare Tunnel (alternative — zero open ports)”Nothing to install except cloudflared:
# on the servercurl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflaredchmod +x /usr/local/bin/cloudflaredcloudflared tunnel login # one-time browser authcloudflared tunnel create dulak# run the tunnel as a service pointing at 127.0.0.1:4000cloudflared service installDashboard → Zero Trust → Networks → Tunnels → add a public hostname
your-domain.com → service http://127.0.0.1:4000. No firewall rules
needed — the tunnel connects outbound.
X-Forwarded-Formatters. Both routes put Cloudflare in front, and the rate limiter keys byX-Forwarded-For/CF-Connecting-IPfirst — only run behind a proxy that sets it, or every client shares the “local” bucket.
Option B — Caddy (no Cloudflare)
Section titled “Option B — Caddy (no Cloudflare)”Caddy handles TLS automatically via Let’s Encrypt.
/etc/caddy/Caddyfile:
your-domain.com { reverse_proxy 127.0.0.1:4000}sudo systemctl enable --now caddyOption C — Nginx + Certbot (no Cloudflare)
Section titled “Option C — Nginx + Certbot (no Cloudflare)”/etc/nginx/sites-available/dulak:
server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:4000; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; }}sudo ln -s /etc/nginx/sites-available/dulak /etc/nginx/sites-enabled/sudo nginx -t && sudo systemctl reload nginxsudo certbot --nginx -d your-domain.comFirewall
Section titled “Firewall”Cloudflare route (A1 — origin rule)
Section titled “Cloudflare route (A1 — origin rule)”Only Cloudflare IPs may reach the app port — this prevents anyone from bypassing the edge and hitting your origin directly. The command below fetches the current IP list once from Cloudflare’s API and creates persistent UFW rules. It does not run per-request; the rules survive reboots. But Cloudflare’s ranges do change, so set up a weekly refresh (see below).
One-time setup:
sudo ufw allow OpenSSH# Fetch live Cloudflare IP ranges (IPv4) and create UFW rulescurl -s https://api.cloudflare.com/client/v4/ips | jq -r '.result.ipv4_cidrs[]' | \ xargs -I{} sudo ufw allow from {} to any port 4000sudo ufw enableWeekly refresh — Cloudflare adds new IP ranges occasionally. Create
/etc/cron.weekly/refresh-cloudflare-ufw:
#!/bin/bash# Remove old Cloudflare rules for port 4000, re-add from live APIufw status numbered | grep '4000' | awk -F'[][]' '{print $2}' | sort -rn | \ xargs -I{} ufw delete {}curl -s https://api.cloudflare.com/client/v4/ips | jq -r '.result.ipv4_cidrs[]' | \ xargs -I{} ufw allow from {} to any port 4000sudo chmod +x /etc/cron.weekly/refresh-cloudflare-ufwSimpler alternative: bind the app to 127.0.0.1 only
(HOST=127.0.0.1) and use A2 (tunnel) — no inbound ports at all,
no firewall rules to maintain.
No Cloudflare (options B/C)
Section titled “No Cloudflare (options B/C)”sudo ufw allow OpenSSHsudo ufw allow 80/tcpsudo ufw allow 443/tcpsudo ufw enablePort 4000 must NOT be exposed publicly — only 80/443 reach the app.
Docker
Section titled “Docker”If running Docker, map the port to localhost only so the proxy can reach it but the outside world cannot:
ports: - "127.0.0.1:4000:4000"Then apply the firewall rules above as if it were bare-metal.