Serving Sites from Your Dev Server
Side projects shouldn’t cost more than they have users. Here’s how I moved mine off a $75/mo hosting bill and onto Docker on my dev server: a Cloudflare Tunnel with no open ports, a trusted CF-Connecting-IP header, JVM memory sized from container limits, a deploy script that tests itself before it touches production, and encrypted off-site backups to R2. It’s not high availability. It’s a home server. But the bill is zero.
Side projects tend to end up costing more per month than they have users. That's not a business model so much as a hobby with an invoice. Once my hosting bill crept toward $75/mo for things only a handful of people used, I stopped paying it.
Instead, I moved everything to Docker on my development server and put Cloudflare in front of it. The monthly cost of keeping a site online dropped to "the server is on anyway."
Here's how it's put together.
The shape of it
Three moving parts, two compose projects:
Internet ──▶ Cloudflare ◀── cloudflared (outbound only)
│ tunnel network
▼
app:8080 (BoxLang + BoxExpress + React build)
│ mysql network
▼
mysql (its own compose project)
- MySQL runs in its own compose project, shared by every site on the machine plus whatever I'm breaking this week. Mine is still named after the managed database it used to mirror, because renaming things is how you find out what depended on the old name.
- The app is one container: BoxLang running BoxExpress, serving the JSON API and the built React app from the same process.
- cloudflared is the only way in.
No open ports
The app's compose file has no ports: entry. Nothing on the machine listens for the site's traffic. cloudflared dials out to Cloudflare, and the tunnel's public hostname in the Zero Trust dashboard points at http://app:8080, which only resolves inside the Docker network.
services:
app:
networks: [tunnel, mysql]
cloudflared:
image: cloudflare/cloudflared:2026.10.1
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${TUNNEL_TOKEN:-}
networks: [tunnel]
depends_on:
app:
condition: service_healthy
networks:
tunnel: {}
mysql:
external: true
name: ${MYSQL_NETWORK:-docker-mysql_default}
No port forwarding, no fighting the ISP's gateway over 443, no caring that my IP changes. cloudflared also waits for the app's health check before it connects, so Cloudflare never gets routed to a JVM that's still compiling.
The cloudflared image is pinned. Cloudflare stops supporting a connector about a year after release, and the tunnel page tells you when you've hit that. I'd rather bump it on purpose than have it bump itself at 3am.
Who's actually asking
Once everything comes through a tunnel, every request appears to come from the cloudflared container. That's great for privacy and terrible for a rate limiter, which suddenly thinks the entire internet is one very enthusiastic user.
Cloudflare passes the real client address in CF-Connecting-IP, so the app trusts that header:
environment:
TRUST_PROXY_HEADER: CF-Connecting-IP
This is only safe because nothing can reach the app except through the tunnel. If you publish a port on the app container while that's set, anyone can claim to be anyone. Put a comment in the compose file saying so. Future you will not remember.
Feeding the JVM
The thing that actually made my sites faster wasn't the move. It was the database no longer starving for memory on a small droplet. The JVM side needs some care too, because a JVM in a container without limits sizes itself for the whole host and then acts surprised.
So compose sets the limits, and the JVM takes its cut from those:
mem_limit: ${APP_MEMORY:-2g}
cpus: ${APP_CPUS:-2}
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=25 -XX:+ExitOnOutOfMemoryError"
ENV BOXLANG_OPTS="-XX:TieredStopAtLevel=4"
- 75% of the limit goes to heap; the rest is for compiled classes, threads and native memory.
- Below roughly 1.8 GB or 2 CPUs, the JVM quietly drops from G1 to the Serial collector. 2g/2 CPUs is the floor for a reason.
- Running out of heap exits the process, and
restart: unless-stoppedbrings it back. A dead container is easier to notice than a sick one. - The BoxLang launcher adds
-XX:TieredStopAtLevel=1for scripts. A long-running server wants full C2 compilation, and the last flag wins.
Because sizing lives in .env, changing it is a redeploy, not a rebuild.
Locking the doors I already locked
The container runs as an unprivileged app user, drops every Linux capability, and can't gain new ones:
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
Port 8080 isn't privileged, so the app doesn't need anything. It's the kind of thing that costs two lines and only matters on the worst day.
Deploys
The production compose file doesn't run from my working copy. There's a separate deployment folder holding exactly three things: deploy.sh, .env (tunnel token, repo, deploy key) and production.env (the app's settings). Everything else comes from GitHub.
./deploy.sh does this:
- Pulls the latest
mainintosrc/over SSH with a read-only deploy key, and hard-resets it sosrc/is exactly that commit. - If the repo's copy of
deploy.shchanged, replaces itself and runs again. - Builds the image with
--pull, so base images update too. - Builds the Dockerfile's
teststage on top of that image and runs the specs in it, against a throwaway test schema on the same MySQL server. Same BoxLang, same modules, same jars as production. If something only works because a module happens to be installed on my machine, it fails here instead of live. - If the specs pass, recreates the app container on the new image and removes the old one. If they fail, it stops and the running app is untouched.
There's a SKIP_TESTS=1 escape hatch for the day I need to ship a fix more than I need to be right.
Migrations run when the app starts, before it listens. The health check has a 90-second start period to cover that and BoxLang's first compile.
Uploads and backups
Anything written to the container's disk disappears on the next deploy, so uploaded images go to a Cloudflare R2 bucket served from its own cdn. subdomain. Metadata (EXIF, location, camera) is stripped on the way in.
The database gets a backup service: the stock mysql image running a small script that sleeps until 08:00 UTC, takes a mysqldump --single-transaction, and keeps two weeks of gzipped dumps locally. With a passphrase set, it also encrypts each dump with AES-256 and copies it to R2: a rolling month of dailies, plus a monthly copy kept for good. Encrypted, because the images bucket is public and I'd like my database not to be.
Fun detail: that image's curl (7.76) has --aws-sigv4, but signs requests in a way R2 refuses. So the backup script builds the AWS Signature V4 by hand with openssl. It's about thirty lines of HMAC and it's the most "40 years of this" thing in the repo.
The passphrase lives somewhere other than the server. The off-site copies exist for the day the server doesn't, and an encrypted backup you can't decrypt is just a very secure way to lose data.
The trade-offs
This is not a high-availability setup. It's a local server.
- Deploys mean a short outage. If your app keeps live state in memory, as mine does, it runs as one container. No rolling restarts.
- If the server sleeps, the site sleeps. Power settings matter more than they used to.
- Reboots are downtime. A few minutes of a side project being offline is not, it turns out, a crisis.
- One disk. Hence the off-site backups.
In exchange: no monthly bill, more memory than the droplet ever had, and deploys that test themselves before they touch anything. For sites that are mostly me and a few interesting links, I'll take it.
No comments yet — be the first.