Self-hosting Plane without a single public port
A 13-service Plane stack on a single-admin VPS: a cloudflared sidecar as the only public door, bind-mounted state, and no public host ports.
TL;DR
Running Plane on a packed VPS clashed with its default host ports 80 and 443. I kept the 13-service stack fully internal and exposed it only via a Cloudflare Tunnel sidecar aimed at the proxy, not port 8000 which is just the API. This keeps the firewall to SSH only, handles TLS at the edge, and removes public port exposure.
One by one the services came up: web, api, worker, minio. Then I re-opened Plane's official compose file and two lines in the proxy service made me frown, a mapping to host ports 80 and 443. A dozen other containers already live on this VPS, and my rule for it is simple: no public host ports at all.
My first guess was that remapping the proxy section to 8080 would be enough. I also assumed the community edition's setup.sh would handle a port move gracefully. Both guesses were wrong.
The community edition flow locks LISTEN_HTTP_PORT to 80 and LISTEN_HTTPS_PORT to 443 [1]. There is no "no ports" option. From the vendor's side that makes sense: the installer is designed to expose everything and let you point a domain at it. On a small VPS that already hosts a pile of other services, though, it feels like renting a room and being forced to cut a new door into a wall that doesn't need one.
Some context: Plane is an open-source project management platform, an alternative to Jira, Linear, or ClickUp, licensed under AGPLv3 [3]. The official requirements are 2 CPU cores and 4 GB of RAM, with 8 GB recommended for production [1]. The docs also warn that production self-hosts should configure an external database and storage, because relying on the machine's own disk risks losing data the moment hardware misbehaves [1]. With resources already spoken for, adding a host-level reverse proxy just makes the budget tighter.
A 13-service stack without host ports
So the move wasn't to fight the defaults but to remove the need for host ports. The stack I adapted from the makeplane/plane community deployment runs 13 services: web, space, admin, live, api, worker, beat worker, migrator, postgres, redis, rabbitmq, minio, and a proxy. Everything that must survive a recreate is bind-mounted under a data directory with fixed user ids: postgres 70, redis and rabbitmq 999, uploads 1000. That keeps file permissions from turning into a mess.
cloudflared runs as a sidecar under a separate compose profile named tunnel. I build its image myself: the binary gets copied out of the official distroless image into a tiny Alpine image, then pinned by digest. The container also idles without TUNNEL_TOKEN, so the same compose file keeps working on a laptop with the tunnel profile switched off.
services:
proxy:
image: makeplane/plane-proxy
# this one lives inside the compose network only,
# no host port mapping at all
cloudflared:
image: cloudflare/cloudflared
profiles: ["tunnel"]
command: tunnel --no-autoupdate run
env_file:
- .env
# no ports block: the tunnel comes to it instead
No ports block anywhere on the cloudflared service. The only port attached to the host is a debug port for triage, bound tightly to the loopback interface. The public path is pure tunnel: create it in the Cloudflare dashboard, run the install command on the server, then publish a route whose Service URL points at the proxy service inside the compose network [2].
The port 8000 trap
This is the part that almost sent me the wrong way. The Service URL example in Cloudflare's docs points at a service on port 8000 [2]. In the Plane stack, 8000 is not the front door: it is the api container's internal port, and the official compose never publishes it [4]. Copy the docs example blindly and the tunnel would hit only the API while the web UI, auth, and static files all fail. The Service URL that works is the proxy, the one service designed to be the single entry for every route.
The model I'm keeping
My call is firm: for a single-admin VPS, a tunnel as the only public entrance is the most sensible option. The host firewall can drop all inbound except SSH. Connections flow from the server toward Cloudflare's edge, not the other way around. Nothing to renew with certbot, no extra nginx config on the host to mistype, TLS handled at the edge.
Proof is easy to check:
docker compose ps --format "table {{.Name}}\t{{.Ports}}"
An empty Ports column, or a single loopback binding, means the stack is clean. Any other number attached to all interfaces means something slipped.
I doubted this approach at first, sure it would add complexity. It went the other way: the firewall rules got shorter, fewer components expose anything, and the same compose file still runs on my laptop without the tunnel. When the next service wants a spot on this VPS, the template is ready: a compose file without a ports block, an optional tunnel, and one debug port on loopback for triage.