DEV Community

Cover image for Self-Hosting a Dockerized App on WSL2, Exposed with a Cloudflare Tunnel
Gerardo León
Gerardo León

Posted on

Self-Hosting a Dockerized App on WSL2, Exposed with a Cloudflare Tunnel

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 Dockerfile that connects to the Postgres already running natively on the Windows host.
  • A docker-compose.yml with its own Postgres container, plus a sidecar container running crond for 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_cron in favor of a plain setInterval in the Node app for the bare-Dockerfile path, and a crond sidecar 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Watch out: the new docker group membership only takes effect in a fresh login session. Until you log out and back in, keep prefixing commands with sudo.

Verify the install with:

sudo docker info
Enter fullscreen mode Exit fullscreen mode

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.internal reaching 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)