This stack runs the production app plus the Yjs collaboration server in Docker, and exposes them to the internet through a dedicated Cloudflare Tunnel — no inbound ports are opened on your machine/router.
browser ─HTTPS─▶ codely.simplemart.dev ─┐ ┌─▶ web:80 (nginx) ─▶ app:3005 (Next.js)
├─ CF edge ─▶ cloudflared ┤ └─(app down)─▶ maintenance.html (503)
browser ─WSS──▶ codely-ws.simplemart.dev ─┘ └─▶ yjs:3006 (Yjs WS)
app + browser ──▶ Supabase (hosted, external)
Why two hostnames? The Yjs server is a separate WebSocket process, and the
browser connects to it directly via NEXT_PUBLIC_YJS_WEBSOCKET_URL. That value
is baked into the client bundle at build time, so it must be a stable public
wss:// URL — hence its own tunnel hostname.
- Docker + Docker Compose (you have both)
- A Cloudflare account with
simplemart.devon it, and Zero Trust enabled - Supabase project (hosted) with the app's schema/migrations applied
(No local cloudflared install is needed — it runs as a container using a token.)
In Cloudflare Zero Trust → Networks → Tunnels:
-
Create a tunnel → type Cloudflared → name it
codely. -
On the Install connector screen, copy the token (the long
eyJ...string). You do not need to run the install command it shows — the composecloudflaredservice uses the token directly. -
Add two Public Hostnames to the tunnel:
Subdomain Domain Service codelysimplemart.devhttp://web:80codely-wssimplemart.devhttp://yjs:3006The service hosts are the Docker Compose service names —
cloudflaredshares the compose network and resolves them. DNS records are created automatically. The app hostname points atweb(the nginx proxy), not the app directly, so users get the maintenance page instead of a raw 502 whenever the app is down (see Maintenance page). WebSockets work over thehttp://yjs:3006origin with no extra settings.
cp .env.docker.example .env.dockerFill it in:
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY— from your Supabase project (copy from your existing.env)NEXT_PUBLIC_YJS_WEBSOCKET_URL=wss://codely-ws.simplemart.devANTHROPIC_API_KEY— for AI lesson notes (copy from.env)SERVER_ACTIONS_ALLOWED_ORIGINS=codely.simplemart.dev— trusted origin(s) for server actions behind the tunnelTUNNEL_TOKEN— the token from step 1
If you use different subdomains, update the Public Hostnames in the dashboard
and NEXT_PUBLIC_YJS_WEBSOCKET_URL to match.
In the Supabase dashboard → Authentication → URL Configuration:
- Site URL:
https://codely.simplemart.dev - Redirect URLs: add
https://codely.simplemart.dev/auth/callback
docker compose --env-file .env.docker up -d --build- App: https://codely.simplemart.dev
- Yjs health: reachable in-network; check container logs for
[yjs] ... listening
Useful commands:
docker compose --env-file .env.docker logs -f # tail all services
docker compose --env-file .env.docker logs -f cloudflared # tunnel status
docker compose --env-file .env.docker ps # service status
docker compose --env-file .env.docker down # stop- Rebuild to change public config.
NEXT_PUBLIC_*is compiled into the client bundle, so editing Supabase or Yjs URLs meansup -d --build, not a plain restart. - No Cloudflare Access. By design, requests are gated by the app's own
Supabase auth. Putting Access in front of the raw Yjs WebSocket handshake
would break the browser
wss://connection. - Ports are internal.
app,yjs, andwebuseexpose(compose network only). To also reach the app onlocalhost, addports: ["8080:80"]to thewebservice.
The web service (nginx) sits between the tunnel and the app and serves
maintenance/maintenance.html — a self-contained
503 page that auto-refreshes — in two situations:
-
Automatic — the app container is unreachable (during
up --build,restart, a crash, or slow boot). nginx can't connect toapp:3005, so it returns the maintenance page instead of a raw 502. It clears itself the moment the app is serving again. -
Planned — you flip a flag file, no rebuild or restart needed:
# Turn maintenance ON (takes effect immediately, per-request check) docker compose --env-file .env.docker exec web touch /etc/nginx/maintenance.enabled # Turn maintenance OFF docker compose --env-file .env.docker exec web rm /etc/nginx/maintenance.enabled
The flag lives inside the container, so it also clears on the next
up/recreate — deliberately fail-open so you can't get stuck in maintenance.
To edit the look or copy, change the files under maintenance/ and rebuild the
web image (up -d --build web). The page is intentionally standalone (inline
CSS, no app assets) so it renders even when the app is fully down.
Note: this nginx page only covers the case where the app container is down but Docker + cloudflared are still up. For a total Docker/host outage (nothing to serve the page locally), an edge Cloudflare Worker serves the same page from Cloudflare's network — see MAINTENANCE.md.
- Server Action blocked / "Invalid Server Actions request": the Host header
is being rewritten. In the tunnel's Public Hostname settings, leave HTTP Host
Header blank (do not override it). Also ensure
SERVER_ACTIONS_ALLOWED_ORIGINSin.env.dockerlists the app's public host (this feedsexperimental.serverActions.allowedOriginsinnext.config.js), then rebuild — it's baked at build time. - Collaboration/cursors don't sync: check
NEXT_PUBLIC_YJS_WEBSOCKET_URLiswss://(notws://) and matches thecodely-wsPublic Hostname; confirm theyjscontainer is healthy (docker compose ... ps). - Tunnel won't start / "unauthorized": verify
TUNNEL_TOKENin.env.dockeris complete and unquoted; checkdocker compose ... logs cloudflared. - Build fails on Monaco/Pyodide assets: these are generated during the build
from
node_modules; make sure the build ran the deps stage cleanly (no network restrictions blockingnpm ci).