Build Docker Images Off-VPS When the Official Registry Lags
The Docker Hub image lags behind the npm release. Build on a big machine, ship it with save, gzip, and scp, then load it on a small VPS.
TL;DR
The Docker Hub image trailed the npm release, and a 964MB VPS can't build a Next.js app without the OOM killer intervening. The fix: build locally, save and gzip the image, scp it over, and docker load it, keeping the old container renamed for instant rollback. Custom images carry different digests, so digest-checking playbooks must skip this server.
Yesterday afternoon I checked the 9router release 0.5.81 that had just landed on npm, ran docker pull on one of my fleet servers, and got an image that was still 0.5.75. The Docker Hub image lags behind the npm package: npm moves first, the official image follows later. The old me would have waited and re-pulled the next morning. Not this time, because the fix I was waiting for is in 0.5.81, and I could not find any official documentation saying when the Hub image follows an npm release.
Two options came to mind: wait, or build on that server. The second died as fast as I remembered the specs. The box has 964MB of RAM, and building a Next.js app takes gigabytes. On a server that size, the OOM killer shoots the build long before compilation finishes.
The custom registry trap in the Dockerfile
Before the transfer pattern, here is the trap that ate the most time. The upstream Dockerfile points package installation at registry.npmmirror.com, and webpack stopped with module not found src/lib/db/driver.js. The error misleads, because that file exists in the source. The culprit sat in the registry: once the install went back to plain npm install, pulling packages from npm's default registry.npmjs.org, the build ran to the end without drama. I cannot tell which package the mirror serves differently. The pattern is what I keep: when a project's Dockerfile forces a third-party mirror and the build fails with an absurd error, the registry is the first suspect. Why upstream uses that mirror, there is still no official explanation, and I simply record it as a build gotcha.
The transfer pattern: save, gzip, scp, load
I ran the build on my local machine, which has far more memory. From there, docker image save packs the image into a tar archive containing all of its parent layers. Piped through gzip, mine shrank to 207MB, small enough for a single transfer.
scp moves the file to the server. Since OpenSSH 9.0, scp uses the SFTP protocol for transfers, so a file this size travels over the same mechanics as a normal SFTP session. On the server, docker image load reads the archive while it is still gzip-compressed, no manual extraction needed, and restores the image with its tags exactly as they were.
docker build -t app-custom:0.5.81 .
docker save app-custom:0.5.81 | gzip > app-0.5.81.tgz
scp app-0.5.81.tgz [email protected]:/tmp/
ssh [email protected] "docker load < /tmp/app-0.5.81.tgz"
Instant rollback and the playbook consequence
The old container does not get deleted right away. The sequence: stop the old container, rename it to 9router-old, then start the new one with identical flags. Same data bind mount, same port, unless-stopped restart policy, same json-file log caps.
docker stop 9router && docker rename 9router 9router-old
docker run -d --name 9router --restart unless-stopped \
-p 20128:20128 -v /root/.9router:/app/data \
app-custom:0.5.81
The unless-stopped policy in this sequence matters for a small reason. A container stopped manually stays down even when the Docker daemon restarts, so the renamed old container never comes back up on its own. Rollback becomes a seconds-long job: if the new container misbehaves, stop it, rename the old one back, done.
Verification is short. curl on the app port returns HTTP 307 toward the dashboard, and the version endpoint shows 0.5.81 with hasUpdate false. Only after both signs appear do I remove the old Hub image so the disk keeps breathing. This server has a history of critical disk trouble caused by Docker build cache, so idle images get cleaned up quickly.
One consequence is easy to miss: any infrastructure playbook that compares local image digests against the Hub must skip this server from now on. A self-built image is guaranteed to carry a different digest than the Hub image. If the playbook forces a re-pull, it overwrites the custom image with the older Hub version. Hosts running custom images belong outside the playbook scope until the Hub catches up.
After this rollout, the pattern became my default for self-hosted apps on small VPS: build where the resources are, ship the image, and let the small server focus on one job, running the finished image.