Skip to content
FreeToolNet
Go back

Building a Next.js Docker image on a low-memory VPS with temporary swap

I deployed a Next.js site to a VPS that also runs several other services. The server had about 2.4 GB of free RAM and no swap. The container itself runs under a 256 MB limit, but building the image is a much heavier job than running it.

I built the first image on the server with a temporary 4 GB swapfile, then removed the swap. Later I moved the build to GitHub Actions so the server never builds at all. This post covers both.

One honest note first. I did not run the build without swap to watch it fail. I checked memory, saw no swap and little headroom on a machine with live services, and added swap before starting. I have no out-of-memory log to show you.

Why the first build happened on the server

The plan was a CI pipeline: build on GitHub, push to a registry, pull on the server. The pipeline needs SSH secrets in the repository, and those were not set yet. I wanted the site up, so I did the first deploy by hand:

  1. Copy a source tarball to the server and unpack it in /opt/portfolio-src.
  2. Build the image there.
  3. Start it with the same compose file the pipeline would use.

The app and its image

The site is Next.js 16.4.5 with React 19, TypeScript, MDX content and some three.js and gsap animation code. It is built with pnpm on Node 24.

The Dockerfile is multi-stage on node:24-alpine. The final stage contains only the Next.js standalone output and starts with node server.js on port 3000. It also defines a health check.

next.config.ts sets output: "standalone" only when DOCKER_BUILD=1 is set. Other builds of the same repository are not affected. The Next.js output docs describe what standalone mode produces.

Adding swap for the build only

Before the build I created a 4 GB swapfile at /swap-build and enabled it. This is the standard swapfile sequence: create the file, restrict its permissions to root, format it with mkswap, enable it with swapon. I did not keep the exact command lines, so I am not printing them here.

The name is deliberate. /swap-build tells me, or anyone else on the box, that this file exists for one job and should not be there afterwards.

When the build finished I turned the swap off with swapoff and deleted the file.

I removed it for two reasons:

  • The swapfile takes 4 GB of disk, and nothing on the server needs it at runtime.
  • I do not want the long-running services on that machine to start swapping quietly. If one of them runs out of memory, I want to see that, not a slow server.

The build command

cd /opt/portfolio-src && DOCKER_BUILDKIT=1 docker build -f deploy/Dockerfile -t ghcr.io/owner/portfolio:latest .

The build context is the repository root and the Dockerfile lives in deploy/, so -f points at it.

The tag is the useful trick. The compose file names the image by its registry path:

image: ghcr.io/owner/portfolio:latest

I tagged the local build with that exact name. docker compose up -d then finds the image locally and starts it without pulling. I did not need a second compose file for the manual deploy, and nothing had to change when CI took over. CI pushes an image with the same name and the server pulls it.

The runtime files live apart from the source, in /opt/portfolio:

cd /opt/portfolio && docker compose up -d

The compose file binds the container to 127.0.0.1:3010, mapped to port 3000 inside, with a 256 MB memory limit. The host nginx proxies the public hostname to that loopback port.

What a manual rebuild looks like

Until CI worked, a redeploy meant repeating the whole thing:

  1. Add the swapfile again.
  2. Rebuild the image in /opt/portfolio-src.
  3. Run docker compose up -d in /opt/portfolio.
  4. Remove the swapfile.

That is four steps where forgetting the first can get the build killed for memory, and forgetting the last leaves 4 GB of swap behind. It works, but it is not something I wanted to do on every content change.

The better fix: do not build on the server

Once the repository secrets were in place, a GitHub Actions workflow took over. It builds the image on GitHub’s runner, pushes it to GitHub Container Registry and restarts the container over SSH. The server only pulls and runs.

After that change:

  • The server needs no swap for deploys.
  • The source tree in /opt/portfolio-src is no longer part of the deploy.
  • Live services on the VPS are not competing with a build for memory.

The container on the server now runs the CI-built image.

This bit me later, on the same image. The site reads a NEXT_PUBLIC_ variable to decide whether to mark itself noindex. Next.js inlines NEXT_PUBLIC_ values when the app is built. Changing the variable in the compose file’s environment section did nothing. The value had to change as a Docker build argument, and the image had to be rebuilt.

If you build on a small server, every one of those changes is another full build with swap. That was one more reason to move the build to CI.

Summary

SituationWhat I did
First deploy, CI not readyBuilt on the VPS with a temporary 4 GB swapfile at /swap-build
Compose file expects a registry imageTagged the local build with the registry name, so no pull
Build finishedswapoff, delete the swapfile
Ongoing deploysBuild on GitHub Actions, server only pulls

If your VPS has a few gigabytes of free memory and no swap, check that before starting next build inside Docker. Temporary swap is a reasonable way to get one build through. For anything you deploy more than once, build somewhere else.

How this post was made

Written with AI assistance and reviewed by the author before publishing. See the editorial policy.

Commands and output were captured on 2026-11-07 on Single VPS shared with other services, about 2.4 GB free RAM and no swap; image built on the server with Docker BuildKit (Next.js 16.4.5, React 19, Node 24 (node:24-alpine), Docker 29, Docker Compose v5).



Previous Post
Deploy a Docker site to a VPS with GitHub Actions, GHCR and SSH
Next Post
Moving a domain apex from Vercel to a VPS with nginx and certbot