Reverse proxy and HTTPS¶
Run Punchlist behind a reverse proxy that handles HTTPS. Browsers only install the app, keep it
working offline and allow its service worker on a secure address (HTTPS, or localhost), so HTTPS
is not optional for real use.
The setup that works best:
Punchlist's own port is plain HTTP. Only the proxy should be able to reach it: do not publish
8080 to your network or the internet. With Docker Compose, leave ports: off the Punchlist
service and put the proxy on the same network, as in
the Caddy example. For what else to set before going on the
internet, see Security.
What the proxy must do¶
Every one of these is needed; without it something breaks.
- Forward everything to Punchlist's port (8080), on one host name, at the root path. Serving it
under a sub-path (
example.com/tasks) is not supported. - Pass
X-Forwarded-ForandX-Forwarded-Proto, so the server knows people's real addresses (for the access log and the sign-in limits) and that the page was loaded over HTTPS (for theSecureflag on the session cookie). Caddy, Traefik and the nginx example below do this. - Not buffer
/api/events: live updates arrive over a long-lived server-sent events stream. A buffering proxy delays every update until the buffer fills. Caddy and Traefik send each event at once by themselves; nginx needs the extra block in its example. - Allow large uploads (200 MB is plenty) for restoring backups through the admin page. Caddy has no limit unless you set one; nginx's default is 1 MB.
Then set PUBLIC_URL to the HTTPS address, and
TRUSTED_PROXIES to the proxy.
Trusted proxies¶
The server believes X-Forwarded-* headers only from addresses in
TRUSTED_PROXIES. The default trusts loopback and
every private range (10/8, 172.16/12, 192.168/16, fc00::/7), so that a proxy on the same host or
Docker network works with no setting.
Why narrow it: anything on a private network that can reach port 8080 directly (another machine
on your LAN, a VPN peer, another container on the same Docker network) can then send its own
X-Forwarded-For and be believed. It can put any address it likes in the access log, and it gets
around the per-address sign-in limit (ten tries a minute) by claiming a new address for every try.
Account lockout still applies. With only the proxy's address set, nothing else is believed:
TRUSTED_PROXIES=127.0.0.1 # proxy on the same machine
TRUSTED_PROXIES=172.30.0.2 # proxy container with a fixed address
TRUSTED_PROXIES=10.0.0.5 # proxy on another machine
When the default is fine: when nothing but the proxy can reach Punchlist at all. In
compose.caddy.yaml Punchlist has no published port and
shares its network with Caddy only, so there is no one else to believe. Setting it anyway costs
nothing.
Caddy behind something else (Cloudflare, Pangolin, a load balancer): Caddy ignores the
X-Forwarded-For it is sent unless told to trust the hop in front of it, so every request would be
logged as coming from that hop. Tell Caddy with its global
trusted_proxies option, and
keep Punchlist's TRUSTED_PROXIES on Caddy.
Caddy¶
The recommended Caddyfile. Caddy gets and renews the certificate itself and redirects http to
https; Punchlist sends its own security headers and compressed answers, so nothing else is
needed:
# Caddy in front of Punchlist on 127.0.0.1:8080. Caddy gets and renews the certificate itself,
# redirects http to https and sets X-Forwarded-For and X-Forwarded-Proto.
# Set TRUSTED_PROXIES=127.0.0.1 on Punchlist.
# Punchlist sends its own security headers and compressed answers: nothing else is needed here.
tasks.example.com {
reverse_proxy 127.0.0.1:8080
}
What people often add, and whether you need it:
| Part | Needed? | Why |
|---|---|---|
tasks.example.com { reverse_proxy ... } |
Yes | The certificate, the redirect to HTTPS and the forwarded headers all come with it. |
encode zstd gzip |
No | Punchlist compresses its answers itself, and its app files come compressed from the build at the highest setting: smaller (216 KB for the app's main script) than Caddy compressing them on the fly (about 267 KB). Harmless: Caddy leaves compressed answers alone. |
header { ... } security headers |
No | Punchlist sends them itself, see security headers. Harmless if you keep yours, with one exception: never set Content-Security-Policy without the ? in front, or yours replaces Punchlist's. |
flush_interval -1 |
No | Caddy already sends server-sent events at once. |
request_body max_size |
No | Caddy has no upload limit unless you set one. If you do, allow at least 200 MB for backups. |
header_up X-Forwarded-*, header_up Host |
No | Caddy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host, and passes Host on, by itself. |
A whole Compose setup with Caddy is in Docker Compose.
On Punchlist 0.2.2 or older, which sent neither, add encode zstd gzip and the security headers
below to the site block.
Security headers¶
Punchlist sets these on every answer itself, so they protect you with any proxy, or none. They are listed here so you know what they do, and so you do not need to add them at the proxy:
| Header | Why |
|---|---|
Content-Security-Policy |
The page may load scripts, styles, images and connections only from your server: if a bug ever let someone inject script, it could not pull in more from elsewhere or send your data away. Attachments and provider icons get a stricter, sandboxed policy of their own. |
Strict-Transport-Security |
Browsers that have been to the site over HTTPS go straight to https for a year, even when someone types or links http://, so nobody on the way can catch a first plain request. Sent only on HTTPS requests and never for localhost. |
X-Frame-Options: DENY |
No other site can show Punchlist inside a frame, so nobody can trick a signed-in admin into clicking buttons on a hidden copy of the admin pages ("clickjacking"). The policy's frame-ancestors 'none' says the same to current browsers. |
X-Content-Type-Options: nosniff |
Browsers take the content type the server gives instead of guessing, so a file cannot be run as a script or page. |
Referrer-Policy: same-origin |
When someone follows a link in a task's notes, the other site does not learn your server's address or which page they came from. |
HSTS needs the proxy to say HTTPS. Punchlist knows a request came over HTTPS from the proxy's
X-Forwarded-Proto, which it believes only from trusted proxies. With the
proxy left out of TRUSTED_PROXIES, the header is not sent.
Not needed, whatever guides say: hiding the Server header (cosmetic, it makes nothing harder
to attack), X-XSS-Protection and Expect-CT (obsolete, current browsers ignore them), and HSTS
preload (it puts your domain on a list built into browsers that is slow to get off).
If something you add needs more (a browser extension that injects script is the usual case),
the browser's console lists what the policy blocked. The proxy can drop Punchlist's policy as a last
resort, in Caddy with header -Content-Security-Policy, at the cost of the protection above.
nginx¶
# nginx in front of Punchlist on 127.0.0.1:8080, with your own certificate (certbot or similar).
# Drop into /etc/nginx/conf.d/punchlist.conf and change the name and certificate paths.
server {
listen 80;
server_name tasks.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name tasks.example.com;
ssl_certificate /etc/letsencrypt/live/tasks.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tasks.example.com/privkey.pem;
# Backups and imports can be large.
client_max_body_size 200m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Live updates: a long-lived event stream that must not be buffered.
location /api/events {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
}
Compression and security headers come from Punchlist; nginx needs no gzip or add_header
lines for them.
Traefik¶
Labels for a Traefik v3 that watches Docker:
# Punchlist behind an existing Traefik v3 that watches Docker labels and has a `websecure`
# entrypoint with a certificate resolver called `letsencrypt`. Adjust the names to your setup.
# docker compose -f compose.traefik.yaml up -d
services:
punchlist:
image: registry.gitlab.com/killswitch10/punchlist:latest
container_name: punchlist
restart: unless-stopped
volumes:
- punchlist-data:/data
# Attached files, apart from the database.
- punchlist-files:/files
environment:
TZ: Etc/UTC
PUBLIC_URL: https://tasks.example.com
networks:
- proxy
labels:
traefik.enable: 'true'
traefik.http.routers.punchlist.rule: Host(`tasks.example.com`)
traefik.http.routers.punchlist.entrypoints: websecure
traefik.http.routers.punchlist.tls.certresolver: letsencrypt
traefik.http.services.punchlist.loadbalancer.server.port: '8080'
networks:
# The network Traefik is attached to.
proxy:
external: true
volumes:
punchlist-data:
punchlist-files:
Traefik streams responses by default; nothing extra is needed for /api/events, and no compress
or headers middleware either.
Apache¶
# Apache httpd 2.4 in front of Punchlist on 127.0.0.1:8080.
# Needs: a2enmod proxy proxy_http headers ssl
<VirtualHost *:443>
ServerName tasks.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/tasks.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/tasks.example.com/privkey.pem
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
# Live updates: an unbuffered, long-lived event stream.
<Location /api/events>
ProxyPass http://127.0.0.1:8080/api/events flushpackets=on timeout=3600
ProxyPassReverse http://127.0.0.1:8080/api/events
</Location>
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
</VirtualHost>
Others¶
- Nginx Proxy Manager: add a proxy host to
punchlist:8080, turn on Websockets support (it also turns off buffering), and request a certificate. - Cloudflare Tunnel: point the tunnel's public hostname at
http://punchlist:8080. Cloudflare's edge may hold server-sent events back; the app still syncs every 30 seconds and whenever it comes back into focus, so changes arrive, just not instantly. - Pangolin, Authelia or Authentik forward auth: fine in front of Punchlist, but keep
/api/*and/auth/*reachable for the app's own sign-in and sync, or sign in only through the proxy and useAUTH_MODE=none(everyone the proxy lets through is the same one user). - The Android app behind Pangolin: Pangolin strips its session cookie from every request before it reaches Punchlist, so the app's browser hand-back (which passes the gate's cookies on) has nothing to pass. Make a share link for the resource in Pangolin instead and paste it into the app's sign-in card; the first load gives the app Pangolin's session the way it gives a browser one. Gates that forward their cookie (Cloudflare Access, Authelia, Authentik) work with the hand-back as documented in apps and sync.