Skip to content

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:

  1. TLS certificate — proves your server is really your-domain.com and encrypts traffic. Browsers refuse to show the lock icon without it.
  2. Listen on port 443 (HTTPS) instead of 4000 — browsers default to 443 for https:// URLs. Port 4000 is non-standard; users would have to type :4000 manually.
  3. 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.

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:

  1. Cloudflare Dashboard → DNS → Records.
  2. 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.
  3. (Optional) Add a www CNAME pointing to your-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:

  1. Dashboard → Rules → Origin RulesCreate rule.
  2. Name: Rewrite host for dulak.
  3. If incoming requests match: Hostname equals your-domain.com.
  4. Rewrite Host header to: your-domain.com.
  5. 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:

Terminal window
# on the server
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared
cloudflared tunnel login # one-time browser auth
cloudflared tunnel create dulak
# run the tunnel as a service pointing at 127.0.0.1:4000
cloudflared service install

Dashboard → 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-For matters. Both routes put Cloudflare in front, and the rate limiter keys by X-Forwarded-For/CF-Connecting-IP first — only run behind a proxy that sets it, or every client shares the “local” bucket.

Caddy handles TLS automatically via Let’s Encrypt.

/etc/caddy/Caddyfile:

your-domain.com {
reverse_proxy 127.0.0.1:4000
}
Terminal window
sudo systemctl enable --now caddy

Option 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;
}
}
Terminal window
sudo ln -s /etc/nginx/sites-available/dulak /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d your-domain.com

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:

Terminal window
sudo ufw allow OpenSSH
# Fetch live Cloudflare IP ranges (IPv4) and create UFW rules
curl -s https://api.cloudflare.com/client/v4/ips | jq -r '.result.ipv4_cidrs[]' | \
xargs -I{} sudo ufw allow from {} to any port 4000
sudo ufw enable

Weekly 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 API
ufw 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 4000
Terminal window
sudo chmod +x /etc/cron.weekly/refresh-cloudflare-ufw

Simpler 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.

Terminal window
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Port 4000 must NOT be exposed publicly — only 80/443 reach the app.

If running Docker, map the port to localhost only so the proxy can reach it but the outside world cannot:

docker-compose.yml
ports:
- "127.0.0.1:4000:4000"

Then apply the firewall rules above as if it were bare-metal.