Reverse proxy (or none)
Crow HR serves plain HTTP on one port and works behind any reverse proxy, or with none on a trusted LAN. No proxy is bundled.
Two settings matter:
PUBLIC_URLis what people type, e.g.https://hr.example.sg. HTTPS cookies follow from it, so TLS can end at the proxy.TRUSTED_PROXIESlists the proxy’s address so the app believes itsX-Forwarded-For/-Proto/-Host. If it is wrong, the app logsIgnoring X-Forwarded-* headers from 172.18.0.5 … set TRUSTED_PROXIES=172.18.0.5. Settings → System → This request andcrowhr doctorshow what the app sees.
Which examples were tested
Section titled “Which examples were tested”| Setup | Status | How |
|---|---|---|
| No proxy (LAN) | Tested | The default compose install in e2e/self-host.sh. |
| Caddy | Tested | e2e/self-host.sh: the shipped Caddyfile.example (below) in a caddy:2-alpine container on the compose network. The test starts that container itself rather than through the shipped override file. Caddy on the host was not run. |
| nginx | Tested | e2e/self-host.sh: the config below in nginx:1.30-alpine, as written in a container on the compose network, and with the host edits below using host networking to stand in for nginx installed on the host. Only nginx 1.30 was run. |
| Traefik | Tested | e2e/self-host.sh: the labels below as written, with traefik:v3.7 in a container on the compose network (Docker provider, exposedByDefault=false, entrypoint websecure on port 443). By hand on a staging server (9 Nov 2026): Cloudflare Tunnel → Traefik v3.7 with a file-provider route → Crow HR. |
| Cloudflare Tunnel | Tested | The same staging server: a remote-managed tunnel (cloudflared 2026.10.4, token) in front of Traefik. Routing the hostname straight to http://app:8080, as below, was not run. |
e2e/self-host.sh runs in CI on every change. For each proxy it sets .env as below (PUBLIC_URL=https://hr.example.sg), sends requests in which the client adds its own X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, and checks:
- With
TRUSTED_PROXIES=private, the app sees the client’s real address (in Settings → System and in the sign-in audit entry), schemehttpsand hosthr.example.sg. The client’s own headers are ignored. - The session cookie is
__Host-andSecure, and plainhttp://redirects tohttps://(Caddy and nginx; the Traefik labels add no port 80 route). - With
TRUSTED_PROXIESempty, the app ignores the proxy’s headers, records the proxy’s own address, and logs the hint above. - Each proxy still reaches the app after the app container is recreated with a new address, which can happen on an upgrade.
Not tested: getting or renewing a Let’s Encrypt certificate, because the test machine has no public name. Instead, Caddy used its internal CA, nginx a self-signed certificate at the paths below, and Traefik (which has no letsencrypt resolver there) its default self-signed certificate. Also not tested: HTTP/3, IPv6, and clients on another machine (the test client is the Docker host).
If you run a setup this page does not cover, please report what worked (or did not) so this table can be updated.
No proxy (LAN)
Section titled “No proxy (LAN)”PUBLIC_URL=http://192.168.1.20:8080TRUSTED_PROXIES=Fine on an office network you trust. Passwords travel unencrypted over the LAN, and Settings → Security shows a “Not using HTTPS” warning. Do not expose this to the internet.
Caddy (automatic HTTPS)
Section titled “Caddy (automatic HTTPS)”Easiest when the machine has a public DNS name. The repository ships an optional override:
cp deploy/caddy/docker-compose.override.example.yml docker-compose.override.ymlcp deploy/caddy/Caddyfile.example Caddyfile # set your domain.env:
PUBLIC_URL=https://hr.example.sgTRUSTED_PROXIES=privateBIND_ADDRESS=127.0.0.1Caddyfile:
hr.example.sg { encode zstd gzip reverse_proxy app:8080}docker compose up -d starts Caddy too. Caddy replaces any client-supplied X-Forwarded-For with the real client address.
Caddy installed on the host instead of in Docker: reverse_proxy 127.0.0.1:8080 and still TRUSTED_PROXIES=private. Docker’s port forwarding hands the app connections from the host via the Docker network’s gateway address (e.g. 172.19.0.1), not 127.0.0.1, so loopback does not match. The test saw this with nginx on the host. Caddy on the host was not run.
nginx 1.27.3 or later in a container on the compose network, for example nginx:1.30-alpine with this file as /etc/nginx/conf.d/default.conf and the certificates mounted read-only (/etc/letsencrypt:/etc/letsencrypt:ro from certbot on the host):
upstream crowhr { zone crowhr 64k; server app:8080 resolve; # looked up again, so nginx follows a recreated app container resolver 127.0.0.11 valid=10s; # Docker's DNS}server { listen 443 ssl; http2 on; server_name hr.example.sg; ssl_certificate /etc/letsencrypt/live/hr.example.sg/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/hr.example.sg/privkey.pem;
client_max_body_size 25m; # uploads (receipts, documents)
location / { proxy_pass http://crowhr; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; # replace, don't append a client's value proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_read_timeout 120s; }}server { listen 80; server_name hr.example.sg; return 301 https://$host$request_uri;}.env: PUBLIC_URL=https://hr.example.sg, TRUSTED_PROXIES=private, BIND_ADDRESS=127.0.0.1.
Avoid a plain proxy_pass http://app:8080;. nginx then looks app up only once, at start. When an upgrade recreates the app container with a new address, nginx answers 502 until it is restarted. The upstream above looks the name up again within 10 seconds.
nginx installed on the host: in the upstream, replace the server and resolver lines with server 127.0.0.1:8080;. Keep TRUSTED_PROXIES=private, because the app sees connections from the host as coming from the Docker network’s gateway, not 127.0.0.1. nginx before 1.25.1 (check with nginx -v) does not know http2 on;; write listen 443 ssl http2; instead.
Traefik
Section titled “Traefik”Add labels to the app service in docker-compose.override.yml, with Traefik on the same Docker network:
services: app: labels: - traefik.enable=true - traefik.http.routers.crowhr.rule=Host(`hr.example.sg`) - traefik.http.routers.crowhr.entrypoints=websecure - traefik.http.routers.crowhr.tls.certresolver=letsencrypt - traefik.http.services.crowhr.loadbalancer.server.port=8080The labels assume Traefik v3 with its Docker provider (exposedByDefault=false), an entrypoint named websecure on port 443 and a certificate resolver named letsencrypt. Rename them to match your Traefik. If Traefik logs Router uses a nonexistent certificate resolver, the name does not match, and Traefik serves its own self-signed certificate instead.
.env: PUBLIC_URL=https://hr.example.sg, TRUSTED_PROXIES=private, and stop publishing the app port to the world with BIND_ADDRESS=127.0.0.1. Traefik sets X-Forwarded-* itself; configure its entrypoint forwardedHeaders.trustedIPs only for proxies in front of Traefik.
Cloudflare Tunnel
Section titled “Cloudflare Tunnel”No open ports needed. Add cloudflared to docker-compose.override.yml:
services: cloudflared: image: cloudflare/cloudflared:latest command: tunnel --no-autoupdate run environment: TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} restart: unless-stoppedIn the Cloudflare Zero Trust dashboard, route the public hostname hr.example.sg to http://app:8080. .env:
PUBLIC_URL=https://hr.example.sgTRUSTED_PROXIES=privateBIND_ADDRESS=127.0.0.1CLOUDFLARE_TUNNEL_TOKEN=…cloudflared sends X-Forwarded-For with the visitor’s address (Cloudflare also sends CF-Connecting-IP, which Crow HR does not read) and X-Forwarded-Proto: https.
Using Singpass login? Cloudflare’s Bot Fight Mode challenges Singpass’s servers when they fetch your JWKS URL, and Singpass then refuses the sign-in. See Singpass login, step 2.
Checking it
Section titled “Checking it”- Open
PUBLIC_URL, sign in as an owner, go to Settings → System → This request: “Your IP as seen” should be your own public or LAN address, schemehttpsbehind TLS, and the badge “Trusted proxy”. docker compose exec app crowhr doctorshows the last request as the app saw it and suggests aTRUSTED_PROXIESvalue when a proxy’s headers were ignored.