HTTPS and reverse proxy
GoodWorkshop belongs behind HTTPS. Without HTTPS there are no passkeys, the session cookie
doesn’t carry Secure, and it travels over the network in plain text, just like the sign-in links.
You have two ways: the bundled tls profile or your own reverse proxy.
The container itself
Section titled “The container itself”app deliberately binds only to 127.0.0.1 — without a proxy in front of it, nothing is reachable
from outside, even with a misconfigured firewall. Two services run in the container:
| Service | Port on 127.0.0.1 |
Variable | What for |
|---|---|---|---|
| Application | 3000 |
GW_PORT |
all pages, /api/…, /api/mcp |
| Collaboration service | 3001 |
GW_COLLAB_PORT |
WebSocket for the live editor, /collab |
The second port exists because Next can’t serve a WebSocket upgrade from a route handler. Both come from the same image.
Way 1: The tls profile (recommended)
Section titled “Way 1: The tls profile (recommended)”docker compose --profile tls up -dThe profile additionally starts a Caddy that takes over ports 80 and 443 and automatically fetches a certificate from Let’s Encrypt. For that you need:
GW_HOSTNAMEin the.env— if the value is missing, Caddy doesn’t start and reports “GW_HOSTNAME muss gesetzt sein: der Hostname, auf den das TLS-Zertifikat lautet.” (German for “GW_HOSTNAME must be set: the hostname the TLS certificate is issued for.”)- a hostname that publicly resolves to this server
- free ports 80 and 443
The bundled Caddyfile also takes care of:
- compression (
encode zstd gzip) - the
Strict-Transport-Securityheader - access logs in their own volume, deleted after 14 days (
roll_keep_for 336h) - forwarding
/collabto the collaboration service and everything else to the app
Way 2: Your own reverse proxy
Section titled “Way 2: Your own reverse proxy”If you already have a proxy on the host (nginx, Traefik, your own Caddy), start the stack without the profile:
docker compose up -dYour proxy then has to do three things.
1. /collab to port 3001, with WebSocket upgrade
Section titled “1. /collab to port 3001, with WebSocket upgrade”Everything under /collab goes to the collaboration service, and the WebSocket upgrade has to get
through unchanged. If /collab lands at the application on port 3000, it can’t serve it.
2. Everything else to port 3000, without buffering
Section titled “2. Everything else to port 3000, without buffering”Next.js streams server components. A proxy that buffers responses delivers pages only in one piece. Turn off buffering for this route.
3. Pass on the real client address
Section titled “3. Pass on the real client address”The app reads the client address from X-Forwarded-For, counting as many entries from the right as
GW_TRUSTED_PROXIES says (default 1). If there are two proxies in a row, set 2. The value is
only used for throttling and logs, never for authorization — a wrong value costs you the
throttling, not the security.
Template: the bundled Caddyfile
Section titled “Template: the bundled Caddyfile”This is how it looks in the repo’s Caddyfile. There the targets are called app:3000 and
app:3001, because Caddy runs in the same Compose network; a proxy directly on the host reaches the
same services via 127.0.0.1 and the ports from the table above.
handle /collab* { reverse_proxy app:3001 }
reverse_proxy app:3000 { # Next.js streams server components; buffering would make the page appear # only in one piece. flush_interval -1 }The app sets its security headers (CSP, frame-ancestors, nosniff, referrer and permissions
policy, HSTS) itself; your proxy doesn’t need to add them.
Other paths for the collaboration service
Section titled “Other paths for the collaboration service”If the collaboration service doesn’t live under /collab on the same hostname, you need
GW_COLLAB_URL (the address for the browser) and possibly GW_COLLAB_INTERNAL_URL (the address
through which the app itself reaches the service when an MCP write enters a room). In the normal
case, both stay empty.
Without a proxy: SSH tunnel
Section titled “Without a proxy: SSH tunnel”To try it out, it also works entirely without a proxy, through a tunnel:
docker compose up -dssh -L 3000:127.0.0.1:3000 serverGW_APP_URL then has to point to the address the browser actually uses. Without HTTPS, passkeys
only work on localhost.
Check that everything gets through
Section titled “Check that everything gets through”| Symptom | Cause |
|---|---|
| The certificate isn’t issued | The hostname doesn’t resolve to this server, or 80/443 are taken |
| The editor permanently shows “No connection. Your changes will be sent as soon as it is back.” | /collab isn’t forwarded, or GW_APP_URL doesn’t match the address in the browser — the socket refuses a foreign origin and writes that to the log |
| Pages appear with a delay and only all at once | The proxy buffers the responses |
| No passkey offered | No HTTPS |
| Claude or ChatGPT can’t connect | The installation isn’t reachable from the internet over HTTPS |