The default advice in 2026 is “just Docker it.” It’s usually right, and it’s sometimes a mistake. Containers buy you reproducibility and isolation, but on a small VPS they also eat memory, add a moving part, and make a single-app box more complex than it needs to be. The honest question isn’t “Docker or not” — it’s “what does your actual setup need?”
What Docker actually buys you
- Reproducibility. The image is your environment. No “works on my machine” drift between local, CI, and prod.
- Isolation. One app’s crash or dependency doesn’t take down the host or its neighbors.
- One-command spin-up.
docker compose upstarts your whole stack — app, DB, proxy — from a file you can version. - Clean upgrades and rollback. Swap an image tag, and if it’s bad, swap it back. That alone is worth a lot.
What it costs you
- Memory. Containers add overhead on top of the process’s RSS (base image + runtime + container layer), and Docker’s memory limit counts the whole container, not just your app.[1] On a 1–2 GB box, app + Postgres + Nginx each in their own container can quietly push you into swap — and swap on a VPS is brutal. Overhead depends on the runtime and base image; measure idle memory per container with
docker statsbefore budgeting. - Image size. A full
node:24image ships the whole toolchain — hundreds of megabytes before your app is in it; a sloppy Dockerfile sends your dev deps to prod. Multi-stage builds are the fix, not a nicety. - One more layer to debug. Networking, volumes, and permissions are a real troubleshooting surface. For a single Node app, that’s overhead you may not need.
- More CPU for the same work. Container runtime adds a thin layer; it’s usually negligible for web apps, but on a tiny box, every bit counts.
The decision framework
| Your situation | Take |
|---|---|
| One app, one server, no team | Raw VPS, pm2/systemd |
| App + database + needs to grow | Docker Compose |
| Multiple apps on one box | Docker Compose (namespaced) |
| You want clean rollbacks across a stack | Docker Compose |
| You’re on a 1GB box | Docker, but lean |
| You’re on disk-constrained hardware | Raw VPS |
The real dividing line is how many independently-versioned services you’re running, and how much you value rollback. One long-lived Node app with a single Postgres behind it: you can run that with systemd and a plain install, fewer layers, less RAM. The moment you want to swap versions of several services together, or hand the setup to someone else, containers win.
The lean middle path (what I’d actually run)
If you’re going to Dockerize, do it without paying the typical overhead tax.
Build small. A multi-stage Dockerfile that ships only the runtime:
FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
If the app doesn’t use Next.js standalone, the runtime copy differs — adjust the COPY lines to your build output.[2] The shape of the FROM … AS runtime stage is what matters: no dev deps, no source tree, no git repo.
node:24-slim (not node:24 full) cuts the base dramatically. Even smaller options (Alpine, distroless) trade debugging ease for size — pick based on how much you’d rather tinker inside a running container.
Set resource limits. The single most useful thing for keeping containers honest on a small box:
# docker-compose.yml
services:
app:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
- NODE_ENV=production
deploy:
resources:
limits:
memory: 384m
cpus: "0.50"
Binding the app port to 127.0.0.1 instead of 0.0.0.0 means you can put Nginx in front (or another container) without exposing the app directly to the internet. The deploy.resources.limits block stops one container from starving the host — especially important if you add a database to the stack.
Add the database in the same file. Typical growth path:
db:
image: postgres:18
restart: unless-stopped
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=myapp
- POSTGRES_PASSWORD=change-me
volumes:
- dbdata:/var/lib/postgresql/data
deploy:
resources:
limits:
memory: 256m
volumes:
dbdata:
Pin a supported Postgres major — 18 is the current stable release (19 was still in beta as of September 2026).[3] Check the image’s memory footprint before setting the limit too low: an under-sized Postgres gets OOM-killed under writes.[1]
Run it.
docker compose up -d --build
docker compose logs -f
docker compose down
When to fall back to the raw VPS
If you’ve got a single app, rows-in-a-file storage or a lean database, no need for multi-container orchestration, and you’d rather minimize memory and moving parts — skip Docker. Use PM2 or systemd. You get:
- Less RAM consumed per process.
- Faster, simpler debugging (no volume/permission layer to trip over).
- Direct access to
node, your package manager, and the filesystem. - Fewer things to explain to the next person who inherits the server.
We publish idle/reload and memory numbers for these two setups only once we’ve measured them on a clean, paid VPS — see how we test. Until then, treat any figures above as structure, not results.
The summary
Docker isn’t a default, it’s a tool for a specific job. Use it when you have multiple independently-versioned services, want reversible deploys, or need to hand the stack to someone else without a 20-page setup doc. Skip it when you have one app, one box, and a strong preference for fewer layers. If you do use it, build small and set limits — that’s where most of the “container overhead” horror stories come from, and it’s avoidable.
Sources
- Docker — Resource constraints (memory limits and OOM behaviour). https://docs.docker.com/engine/containers/resource_constraints/ — checked 2026-09-27
- Next.js —
output: 'standalone'(next.config.jsreference). https://nextjs.org/docs/app/api-reference/config/next-config-js/output — checked 2026-09-27 - PostgreSQL — Versioning policy (current supported majors). https://www.postgresql.org/support/versioning/ — checked 2026-09-27