Moving DevTools Daily's backend from Azure to a $10 VPS

2026-07-23

Moving DevTools Daily's backend from Azure to a $10 VPS

I started working on DevTools Daily about four years ago with a simple goal: build a static website for everyday tasks like JQ testing and JSON formatting that shows no ads and processes everything in the browser. That last part mattered to me — I wanted to be able to paste data both at home and at work without worrying about leaks.

Over time it grew into a bigger collection of playgrounds and cheatsheets, but it's still a 99% static website (Next.js rendered as a static export). Recently I moved the small backend that powers the non-static features off Azure and onto a plain VPS, and I've been surprised by how much simpler my life got. Here's the story.

Why I needed a backend at all

The site is static by design, but a few features can't run purely in the browser:

  • Example GraphQL / REST calls
  • AI calls to OpenAI / Gemini — these use secret API keys, so they can't happen in the browser without leaking the keys
  • Storage for premium users
  • Most recently, interview questions with real code execution

Starting on Azure

I initially chose Azure for the backend, mostly because I already had a subscription. I added a managed Node.js server, and it worked fine. Later I added managed MySQL to store bookmarks and data for a couple of the mobile apps.

For a while I was mostly happy, but a few things kept bothering me:

  • Slow deploys — a simple Node.js app took ~20 minutes to deploy.
  • Monitoring — there's no ready-to-use dashboard with logs and metrics. You end up writing Kusto queries just to see what's going on.
  • Complexity — the control surface is enormous. There are a million knobs, and most of them I never touch. (To be fair, AWS and Google Cloud are equally complicated.)

What finally pushed me off

The annoyances alone wouldn't have moved me. The real trigger was new requirements:

  • Backend language support for interviews — I wanted to run Java, Go, and Rust, not just Node.
  • Running AI agents in a remote sandbox instead of on my local machine.

Both of these are much easier when you fully own the box.

Getting a VPS (and letting AI set it up)

I grabbed a Hetzner VPS for around $10/month — 4 cores, 8 GB of memory. Plenty for my traffic.

The nice part in 2026: setting up a dev box is easier than it used to be, because you don't have to remember every command. My workflow was basically:

  1. SSH into the box.
  2. Start Copilot / Codex right there in the terminal.
  3. Ask it for the next step, review, and run it.

I also recorded the whole setup in a GitHub repo as I went, so it's reproducible — if I ever need to rebuild the box (or spin up a second one), I have the steps and configuration checked in instead of trapped in my shell history.

The setup I landed on

I put Caddy in front as a reverse proxy. It routes requests to the right container based on hostname:

Each application runs in its own Docker container and listens on a different port. Caddy is the only thing bound to the public interface (ports 80/443); every application port binds to 127.0.0.1 and is never exposed directly. Caddy picks the right container by hostname, terminates TLS, and adds the shared security headers (HSTS, X-Content-Type-Options, and friends) in one place.

Two repos: gateway vs. apps

One decision I'm happy with is splitting infrastructure into two kinds of repos:

  • A single gateway repo owns only public routing — the Caddyfile, one small sites/<app>.caddy fragment per hostname, reusable security/logging snippets, and a PORTS.md registry so loopback ports never collide.
  • Each application repo (like the backend API) owns its own Dockerfile, Compose file, volumes, secrets, health checks, and deployment lifecycle.

Adding a new service is then a checklist: reserve a loopback port, confirm the app only listens on 127.0.0.1, drop in a sites/*.caddy fragment, reload Caddy. Credentials stay in the app's environment and never touch the gateway.

How the backend is packaged

The backend is a Node.js app built with a multi-stage Dockerfile on node:22-bookworm-slim. The first stage installs native build tools and runs npm ci --omit=dev; the final stage copies in the pruned node_modules and bakes the Playwright Chromium binaries into a shared image layer. A Docker HEALTHCHECK hits a /readyz endpoint so the orchestration can tell a booting container from a ready one.

Compose runs the same image as two slots (blue and green) using a YAML anchor so the config stays DRY, with guardrails on each slot:

x-api: &api
  image: ${API_IMAGE:-api:latest}
  restart: unless-stopped
  mem_limit: 1280m
  cpus: 1.25
  pids_limit: 512
  stop_grace_period: 45s
  logging:
    driver: json-file
    options: { max-size: "10m", max-file: "5" }
  volumes:
    - api_data:/app/data       # SQLite + generated data live here
    - api_uploads:/app/uploads

The sandbox that executes untrusted interview code lives in its own container on a shared Docker network, also bound only to 127.0.0.1. The backend reaches it over that internal network and authenticates with a shared secret — the sandbox is never routed publicly.

Blue/green deployments

Deploys run through a self-hosted GitHub Actions runner on the box (runs-on: [self-hosted, linux, api]). On a push to main it runs the tests, fast-forwards a canonical checkout to origin/main, and then runs a blue/green script. The flow:

  1. Figure out which slot is currently inactive, docker build the new image, and start the inactive slot on its loopback port.
  2. Poll docker inspect health and /readyz until the new slot is actually serving (giving up after a few minutes and leaving the old slot untouched if it never gets healthy).
  3. Flip traffic by rewriting the upstream and running caddy reload — a graceful reload that finishes in-flight requests, so no connections are dropped. If validation or reload fails, it restores the previous upstream and reloads again.
  4. Let the old slot drain for ~120s, then stop it. A safety check makes sure a newer deploy hasn't already switched traffic again before stopping anything.

No more 20-minute waits, and no dropped requests during a deploy. Old images get pruned at the end so the disk doesn't fill up.

Monitoring

Monitoring was one of my bigger complaints about Azure, so I wanted something simple I could actually glance at. It now lives right alongside the gateway in the infra repo — each piece is a small Compose stack with its own Caddy route — and I split it into two pieces:

  • Beszel for metrics — CPU, memory, disk, and per-container resource usage with a clean dashboard out of the box (a hub plus a host agent).
  • VictoriaLogs for logs, fed by Fluent Bit, which tails both the Caddy access log and the Docker container logs and ships them into a single searchable index. It's tuned tiny on purpose — short retention and a small memory cap — so it barely registers on the box.

Together they give me the "logs + metrics in one place" experience that took a pile of Kusto queries on Azure.

I'll be honest, though: this part is still experimental. I haven't fully settled on the stack, and I'm considering consolidating onto OpenObserve instead, which handles logs, metrics, and traces in a single system. If I switch, I'll write it up.

Gotchas

  • Restarts for updates — roughly once a month I need to restart for OS/software updates. I worried this would be painful, but it turned out to be a non-issue: the whole thing takes about 30 seconds. I've got pre- and post-reboot check scripts in the infra repo that confirm Docker, Caddy, and the monitoring containers all come back healthy, so if I do it around midnight once a month, nobody notices.

What's next

I've got a couple of follow-up posts planned:

  • Switching from hosted MySQL to SQLite — why I'm doing it, and how it changes backups and deployment.
  • Running an autonomous agent on the VPS — how I set up an AI agent to work on the box on its own.

Stay tuned.