TL;DR: A proof-of-concept to-do app — Node/Express and Postgres, running in Docker on WSL2 — reachable at a real domain through a named cloudflared tunnel. No login system, just a per-browser cookie hash. If you already have WSL2 and a Cloudflare-managed domain, expect this to take a few minutes, not hours.
I wanted a small proof of concept: a to-do list with real persistence, running in Docker, reachable from my own domain instead of localhost. Nothing about that sounded hard on paper to me, but knew I was a bit rusty on it. So, brushing off the dust, I encountered with three lessons I'm sharing here with you... besides of the fact I don't want to be paying for small hosting for small PoCs lol
What You'll Need
- Windows OS
- WSL2 with an Ubuntu distro
- Docker Engine installed inside WSL2 (not Docker Desktop — more on why below)
-
cloudflared(I used v2026.9.1) - A domain managed in Cloudflare, so you can route a subdomain to a tunnel
What We're Building
The app itself is deliberately small: Node.js and Express on node:alpine, one Postgres table (todos), and no real authentication. Each visitor gets an httpOnly cookie with a random hash (crypto.randomBytes(16).toString('hex')), and every query filters by that hash. That gives you a separate list per browser without a login screen — fine for a POC, not something I'd ship for anything that needed real accounts. The table clears itself every two hours, with a warning in the UI so nobody's surprised when their list disappears.
I built two ways to deploy it, mostly to compare them:
- A bare
Dockerfilethat connects to the Postgres already running natively on the Windows host. - A
docker-compose.ymlwith its own Postgres container, plus a sidecar container runningcrondfor the periodic cleanup.
One of these paths turned out to be a dead end, for reasons that only showed up once I changed how Docker itself was running — which is the first detour.
Why Not pg_cron?
Before reaching for an app-level timer, I checked whether Postgres could handle the two-hour cleanup itself, natively, via pg_cron. It turns out pg_cron does have real, current support on Windows. It just isn't a CREATE EXTENSION away: you have to compile it yourself with Visual Studio Build Tools (nmake /F Makefile.win) and touch shared_preload_libraries, which means restarting the Postgres service.
Lesson: for a POC scoped around "minimum requirements," a build toolchain and a service restart is too much ceremony for a job that runs once every two hours. I dropped
pg_cronin favor of a plainsetIntervalin the Node app for the bare-Dockerfile path, and acrondsidecar container for the docker-compose path — the more idiomatic Docker pattern for periodic jobs anyway.
From Docker Desktop to Docker Engine on WSL2
I started this project thinking of Docker Desktop, though favored later for the raw Docker Engine to be in flavor country (Homer S. line, lol) and the way for this is installing WSL2 in Windows, which takes barely a command in Powershell:
wsl --install -d Ubuntu
Notice: This requires a Windows restart. When Ubuntu comes back up, it'll ask you to create a Unix user and password, BAU.
Inside Ubuntu, install Docker Engine by running the following:
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
Watch out: the new
dockergroup membership only takes effect in a fresh login session. Until you log out and back in, keep prefixing commands withsudo.
Verify the install with:
sudo docker info
The Networking Catch
Moving Docker into WSL2 broke the bare-Dockerfile path. With Docker Desktop, host.docker.internal reached the Postgres instance running natively on Windows without any fuss. Once Docker was running inside the WSL2 VM itself, that same hostname had to cross two separate network hops instead of one, and it stopped resolving cleanly.
Watch out: if you're moving from Docker Desktop to Docker Engine in WSL2 and something depended on
host.docker.internalreaching a service on native Windows, expect it to need rework. Rather than fight WSL2-to-Windows networking, I just used the docker-compose path — Postgres in its own container, inside the same WSL2 VM as the app — and the problem disappeared.
Deploying the Stack
With Docker Engine running inside WSL2, bringing the stack up looks like any other docker-compose project:
cd /mnt/c/%PROJECT%/todo-docker
cp .env.example .env
# edit .env: DB_USER, DB_PASSWORD, DB_NAME
sudo docker compose up --build -d
sudo docker compose ps
curl -i http://localhost:3000
The project files live on the Windows filesystem, mounted into WSL2 at /mnt/c/.... Docker's build runs against that path with no copying step and no surprises — WSL2's filesystem interop handles it transparently.
Exposing It: Cloudflare Tunnel
With the app answering on localhost:3000, the last piece is making it reachable at a real domain. cloudflared handles that without opening a port on your router:
cloudflared.exe tunnel login
cloudflared.exe tunnel create todo-docker-poc
cloudflared.exe tunnel route dns todo-docker-poc todo-list-docker.gerardoleon.dev
cloudflared.exe is a native Windows binary. WSL2 can call it directly through Windows interop, since it's already on the Windows PATH that WSL inherits — there's no need to install a second copy inside the Ubuntu distro.
After filling in cloudflared/config.yml with the tunnel ID from tunnel create, running the tunnel looks like this:
cloudflared.exe tunnel --config "C:\%PROJECT%\todo-docker\cloudflared\config.yml" run todo-docker-poc
The Path Gotcha
This is the one that cost me the most time for the smallest reason. cloudflared.exe, even when you call it from WSL2's bash, is still a Windows process — it interprets paths the Windows way, not the Linux way. Passing it --config /mnt/c/Users/.../config.yml fails with "the system cannot find the path specified," which reads like a permissions problem but isn't one.
Watch out: when a Windows binary is invoked from WSL2 via interop, give it Windows-style paths (
C:\...\config.yml), not the/mnt/c/...form WSL2 itself uses. The binary's process, not the shell calling it, decides how paths get parsed.
Troubleshooting
| Problem | Symptom | Fix |
|---|---|---|
pg_hba.conf only allows 127.0.0.1/::1
|
Docker container can't reach native Postgres | Add a scoped host rule for the Docker bridge subnet, e.g. host tododb todoapp 172.16.0.0/12 scram-sha-256
|
host.docker.internal unreachable after moving to WSL2 |
Bare-Dockerfile path can't reach host Postgres | Use the docker-compose path instead — Postgres in its own container, same WSL2 VM |
cloudflared.exe says "can't find the specified path" |
Tunnel won't start with a /mnt/c/... config path |
Pass the Windows-style path instead — cloudflared.exe parses paths as Windows, not WSL2 |
docker group permission denied |
Can't run docker without sudo right after install |
Log out and back in for group membership to apply, or use sudo for the rest of that session |
Achievements 🎉🙌
- Self-hosted a real Postgres-backed app in Docker, running inside WSL2
- Exposed it at a real FQDN (
todo-list-docker.gerardoleon.dev) through a named cloudflared tunnel - Verified it end-to-end: adding, checking off, and deleting tasks through the public URL, with persistence actually holding up in Postgres
For a deeper look at wiring up automated publishing around a project like this, see connecting a GitHub repo to a package registry. If you want to take this further with real accounts instead of a cookie hash, that's the natural next post.
Try it yourself cloning here: https://github.com/elparaquecosadeque/todo-list-docker
FAQ
Do I need Docker Desktop for this?
No. Docker Engine inside WSL2, installed with the official convenience script, is enough — Docker Desktop was actually the thing I moved away from here, not a requirement. The app/Postgres/docker-compose side of this has nothing WSL2-specific in it either: it's the same Docker Engine install and the same compose stack you'd run on a standalone Ubuntu box, since Postgres lives in its own container and never has to reach out to a host OS. The one part that would change is exposing it — on standalone Ubuntu you'd install the native Linux cloudflared binary instead of cloudflared.exe, and none of the Windows-path gotcha from this post would apply, since there's no Windows layer in between.
Why not just use pg_cron for the cleanup job?
pg_cron has real Windows support, but it needs a compiled build via Visual Studio Build Tools and a Postgres service restart to enable shared_preload_libraries. For a two-hour cleanup timer on a minimal POC, an app-level setInterval or a crond sidecar container gets the same result for a fraction of the setup cost.
Why WSL2 instead of Docker Desktop on native Windows?
Mainly to run Docker Engine directly, without the Desktop app, and to actually learn WSL2 instead of treating it as a black box underneath Docker Desktop. The trade-off is that anything relying on host.docker.internal to reach a service on native Windows may need rework — in this case, moving Postgres into the same compose stack as the app.



Top comments (0)