deploy · tutorial · vps

Docker Compose + Next.js: Don't Let It Eat Your SQLite

One compose file for app plus volumes: SQLite survives rebuilds, deploys stay boring, backups stay automatic. Full YAML inside.

Workers stacking a huge pile of crates beside a train, 1918
Workers stacking a huge pile of crates beside a train, 1918 · Photo: National Library of Scotland, no known copyright restrictions, via Wikimedia Commons

"just dockerize it" deletes the database

The standard Docker tutorial ends with a running container and a silent time bomb: SQLite written inside the container filesystem. First image rebuild, first prune, first host migration — the database goes with the layer.

Then comes the second surprise. The container that built fine on a Mac wants 2GB at build time on the VPS, the volume mount has wrong ownership, and WAL files scatter across anonymous volumes nobody listed.

Docker compose Next.js SQLite is genuinely good on a VPS — reproducible builds, one-file topology, clean rollbacks. It just needs the volume rule plus two guards nobody puts in the quickstart.

Bare PM2 vs careless compose

Concern Bare PM2 (no Docker) Careless compose Careful compose
SQLite survives rebuild Yes — plain file No — dies with layer Yes — named volume
Build reproducibility Depends on host state Bit-identical image Bit-identical image
RAM on 1–2GB box ~250 MB app ~250 MB + ~50 MB shim Same + limits set
Rollback Symlink switch image:prev retag image:prev retag
Backup story Cron copies a file Nothing (volume forgotten) Nightly VACUUM INTO job

Three details decide whether compose helps or haunts:

  1. Anonymous volumes are invisible. A volumes: [/data] line without a name creates a hash-named volume per rebuild. Old databases pile up, the live one is unfindable, and docker volume prune becomes Russian roulette.
  2. Build-time RAM doubles. The build container compiles while PM2 (or the old container) still serves. Same OOM spike as bare metal, now with less visibility — the swapfile post applies verbatim inside compose flows.
  3. File ownership crosses the boundary. Root-built images write db.sqlite as root; the next non-root run cannot open it. One user: line and one chown in the entrypoint ends the class forever.

None of this argues against Docker. It argues for one reviewed compose file instead of five improvised docker run flags.

One file, one volume, one backup

The shape BoringPush documents in its deployment guide: app container plus a named volume, nothing else moving.

Step 1 — Write the compose file with a named volume and limits.

compose.yaml
services:
  app:
    build: .
    image: boringpush:latest
    restart: unless-stopped
    ports: ["127.0.0.1:3000:3000"]
    environment:
      # your production settings go here; point the SQLite file at /data (see the deployment guide)
    volumes:
      - appdata:/data
    deploy:
      resources:
        limits: { memory: 900M }
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
      interval: 30s
      retries: 3
volumes:
  appdata:

Every line earns its place: 127.0.0.1: binds the app to localhost (Cloudflare Tunnel reaches it, the internet cannot), the named appdata volume outlives every rebuild, the memory limit keeps one bad deploy from eating the host, and the healthcheck gates rolling updates on truth instead of hope.

Step 2 — Build lean so small boxes survive.

# Dockerfile (sketch): standalone output keeps runtime tiny
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim
WORKDIR /app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/public ./public
USER node
CMD ["node", "server.js"]

Standalone output means the runtime image carries traced files only — smaller pull, smaller RAM, faster boot. .dockerignore excludes .git, .next, and local *.db so dev databases never ship to prod. Multi-arch worry disappears because the VPS builds its own image natively.

Step 3 — Back up the file, not the container.

# nightly cron on the host (not in compose):
sqlite3 /var/lib/docker/volumes/app_appdata/_data/app.db \
  "VACUUM INTO '/backups/app-$(date +%F).db'"

VACUUM INTO snapshots a consistent database without stopping the app — no downtime, no lock drama. Keep seven days, sync weekly off-box with the env-sync post's one-command pattern.

Restoring is copying one file back and restarting one service. Boring in the best sense.

That's the product — the topology fits in one file you can read from your phone.

The rebuild test

Check Command Pass looks like
Volume named docker volume ls app_appdata, no hash names
Data survives rebuild docker compose up -d --build twice Same row counts, same users
Not publicly bound curl -m 5 http://YOUR_VPS_IP:3000 Refused (localhost-only)
Backup fresh ls -l /backups/ Today's timestamped .db
Restart policy docker reboot drill App returns alone

Said without spin: compose adds ~50MB of daemon overhead and one more abstraction between you and the logs (docker compose logs -f instead of pm2 logs). Debugging gets one layer deeper exactly when you are most stressed.

BoringPush accepts that for the reproducibility — same file on staging and prod means the bug you fixed once stays fixed. If the abstraction ever scares you at 2am, the bare-PM2 path from the Hetzner guide runs the same code with no translation.

FAQ

Does docker compose nextjs sqlite perform as well as bare metal?

Yes for solo scale — the overlay and volume shims cost single-digit microseconds per query against 0.1ms SQLite reads. The database file sits on the same NVMe either way.

What costs real performance is misconfiguration (journal on overlay, no WAL), not the container. Keep WAL mode and the named volume and you will never measure the difference.

Should Postgres also live in compose next to the app?

Only for staging. In prod, one more stateful container doubles backup surface and memory pressure on a small box.

Keep SQLite in a volume for solo scale and switch the database setting to managed Postgres past it — same queries, no second container to nurse. Compose carries the app; the data layer graduates separately.

How do zero-downtime reloads work with compose?

Add a second replica behind the tunnel only if the box has RAM for two app containers — otherwise rolling updates still gap briefly. Most solo apps accept a 3-second swap (up -d with the healthcheck) over doubling memory. The PM2 reload post covers true zero-downtime for bare metal; in compose, honesty about the gap beats pretending it away.

Where do image tags and rollbacks live?

Tag every build with the short SHA alongside latest, keep the previous image local, and roll back with image:<prev-sha> plus up -d. No registry needed on one box. Prune monthly (docker image prune) so old tags do not eat the disk that SQLite needs.

Can I still deploy with git push production main?

Yes. BoringPush includes a Docker path that builds the image and brings the container up, with the same one-command feel and a container at the end. Phone deploys work identically.

What breaks most often in compose SQLite setups?

Ownership (root-owned db file — fix with USER node plus entrypoint chown), anonymous volumes (fix: always named), and disk-full from untagged images (fix: monthly prune plus the 90-day cleanup cron). All three are one-line preventions and multi-hour cures. Write the lines now.

Keep reading

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading