# 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.

Source: https://boringpush.com/blog/docker-compose-nextjs-sqlite-vps

## "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.**

```yaml 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
# 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.**

```bash
# 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

- [Deploy Next.js on Hetzner $5 by Dinner Tonight](/blog/deploy-nextjs-hetzner-vps) — the box this compose file lands on
- [git push production deploy: Skip the 9-Minute CI Queue](/blog/git-push-production-main-deploy) — the hook that builds this image
- [Next.js 'Killed' on 1GB VPS? The 5-Minute Swapfile Fix](/blog/nextjs-oom-build-fix-1gb-vps) — the swapfile that saves compose builds too
