Running Next.js on a 2GB VPS: what broke in our CI/CD
Our production site runs on a small Vultr server with 1 CPU and 2GB of RAM. Google Cloud Run is there only as a backup. Nothing is built on the server itself: we push to main and GitHub Actions does the rest.
These are notes from a real deployment: what worked, what broke, and what we changed.
The setup
It's one repository with two Next.js apps on the same domain:
- The main site at
/, which lives at the root of the repo. - The product app in
apps/web, served under/apps/<product-slug>.
That's two apps, two containers, and one domain.
We kept Cloud Run as a backup so we can point DNS back to it if the server goes down. The server is the main production host because it's cheap, we control it fully, and it's enough for where the product is right now.
One rule shaped everything else: a 2GB server can't build Next.js. next build uses too much memory. So we build on GitHub Actions, push images to GitHub Container Registry (GHCR), and only run them on the server.
How it fits together
All traffic arrives at nginx on ports 80 and 443. nginx sends / to the site container on 127.0.0.1:8080 and /apps/ to the product container on 127.0.0.1:8081.
The pipeline is simple:
- Push to
main. - GitHub Actions builds both images.
- It pushes them to GHCR.
- It runs smoke tests on the CI runner.
- It sends the images to the server and runs
docker compose up.
The containers aren't exposed to the internet. Only nginx is public. Certbot runs directly on the server next to nginx, not in Docker, which keeps TLS and routing simple.
How the infra folder is organized
| Path | What's in it |
|---|---|
infra/platforms/shared/ | Docker Compose files and deploy scripts that work on any VPS provider |
infra/platforms/vultr/ | Server setup script, nginx template, and config sync for Vultr |
infra/platforms/gcp/ | Terraform for the Cloud Run backup |
If we move to Hetzner later, we copy vultr/ to hetzner/ and adjust the setup script. Everything in shared/ stays the same.
On the server, the app lives at a fixed path:
/home/<deploy-user>/workspace/projects/<repo-name>/
The one-time setup files are in ~/workspace/bootstrap/vultr/. We didn't use /opt because the team already had a workspace layout. The exact path doesn't matter much, as long as you pick one and stick to it.
GitHub setup before the first run
Secrets
Add these under the repository's Settings > Secrets and variables > Actions:
| Secret | What it is |
|---|---|
VULTR_HOST | The server's IP address |
VULTR_USER | The SSH user, for example deploy |
VULTR_SSH_KEY | The private key CI uses to log in |
Create a separate key pair just for CI and add its public key to authorized_keys on the server. Don't reuse your personal key.
Letting Actions push to GHCR
If your GitHub organization sets GITHUB_TOKEN to read-only, the build job can't push images. Either an org owner allows read and write access, or the workflow adds permissions: packages: write.
GHCR names must be lowercase, so an org called MyOrg becomes ghcr.io/myorg/my-web/site:sha-xxxxxxx. We tag each image with sha- plus the first 7 characters of the commit, so we always know exactly which version is running.
After the first build, open the package's settings, link it to the repository, and give that repository's Actions access. If you skip this, later workflow runs may not be able to pull the image.
The workflow: three jobs
The workflow lives in .github/workflows/deploy-production.yml.
| Job | What it does |
|---|---|
| build | Builds and pushes the site and product images, tagged sha-xxxxxxx and latest |
| smoke | Pulls the images on the runner, starts them, and checks /api/health and /apps/<product-slug> with curl |
| deploy | Sends the images to the server and runs docker compose up |
If smoke passes but deploy fails, the problem is almost always SSH or the server, not the build.
Why we stopped pulling from GHCR on the server
The first version did the usual thing on the server:
docker login ghcr.io
docker compose pull
docker compose up -d
This worked on the CI runner. On the server it failed with 403 Forbidden, both with a personal access token that had read:packages and with the workflow's GITHUB_TOKEN passed over SSH.
A curl to the GitHub user API returned 200, but a curl to the GHCR image manifest returned 403. The token was valid, it just wasn't allowed to read the org's package. Giving a repository Actions access to a package only helps workflows in that repository. It doesn't help a personal token pulling from some other machine. If your org uses SAML single sign-on, also check that the token has been authorized for the org, because an unauthorized token behaves the same way.
What we did instead
Now CI ships the images directly. The runner already has them from the smoke test, so it runs docker save, compresses the result, copies it to /tmp/ on the server, runs docker load, deletes the file, and then runs DEPLOY_SKIP_PULL=1 ./scripts/deploy.sh.
| Pull on the server | Ship over SSH | |
|---|---|---|
| Needs GHCR login on the server | Yes | No |
| Data transferred | Less, because unchanged layers are cached | The full image every time |
| Worked for us | No, it failed with 403 | Yes |
We're sticking with SSH for now. For a small server with light traffic, a few extra minutes per deploy is fine. If the org's GHCR permissions get sorted out, we can switch back, since deploy.sh supports both.
/tmp/ is just a short-lived place for the image file, not where the app runs. Delete the file after docker load.
First-time server setup
bootstrap.sh installs Docker, nginx, Certbot, and the ufw firewall, adds 2GB of swap, and copies the nginx config to /etc/nginx/sites-enabled/<app-name>. Don't skip the swap: without it, a 2GB server freezes easily.
Log out and SSH back in after the script runs, so your user picks up the new docker group.
At one point the containers were healthy but the site was down. The setup script had never run, so nginx had no config for the site. A clean docker ps doesn't mean production is working.
Quick checks:
ls /etc/nginx/sites-enabled/<app-name>
curl -H "Host: example.com" http://127.0.0.1/api/health
DNS and Certbot
At your DNS provider, set up:
- An A record for
@pointing to the server's IP. - An A record for
wwwpointing to the server's IP. - No AAAA (IPv6) records unless the server really has IPv6.
Certbot first failed with a 404, and the logs showed an IPv6 address starting with 2001:4860:. That range belongs to Google, so it was most likely a leftover record from the Cloud Run setup. Let's Encrypt was checking Google's server instead of ours. We removed the AAAA record, waited for DNS to update, and checked that the challenge path reached our server:
curl http://example.com/.well-known/acme-challenge/test
Once plain HTTP worked, we ran:
sudo certbot --nginx -d example.com -d www.example.com
The CSS bug from sharing one domain
The product app's pages load from /apps/..., so nginx sends them to the product container. But by default, Next.js loads its CSS and JavaScript from /_next/static/..., and nginx sends / paths to the main site container.
So the product pages showed text with no styling. In DevTools, the CSS requests returned 404 or loaded the wrong file.
The fix was setting assetPrefix: "/apps" in apps/web/next.config.ts. Now its assets load from /apps/_next/static/... and reach the product container. Another option is basePath: "/apps/<product-slug>", which moves both the pages and the assets under that path in one setting.
If you run two apps on one domain, plan how static files are routed before launch, not after.
What a normal release looks like now
- Merge or push to
main. - Wait for build, smoke, and deploy to pass.
- Check the site in a browser, and do a hard refresh if CSS or JavaScript changed.
There's no manual SSH deploy, and no GHCR pull on the server.
CI doesn't touch nginx or the TLS certificates. Changes to either are still done by hand on the server.
- Server-side environment variables in
.env: edit the file on the server, then rundocker compose up -d, which recreates the containers with the new values. NEXT_PUBLIC_*variables: these are baked in when the app is built, so changing them needs a new image.
Checklist
- GitHub secrets (
VULTR_*) are set, and Actions can push packages. - The server has been set up, with config and
.envsynced. - DNS has the A records and no stray AAAA records.
- You've pushed to
mainand CI passed. - Certbot ran after HTTP was working.
- Both
https://example.com/andhttps://example.com/apps/<product-slug>load correctly.
Keep the full copy-paste commands in a runbook in your repo, for example docs/PRODUCTION-DEPLOY.md.
Production is a lot more than docker run. It's DNS, nginx, TLS, registry permissions, and routing two Next.js apps on one domain, and tutorials rarely match how your org has set up GHCR.
A 2GB server is enough if you build in CI, handle TLS in nginx, and remember that a GHCR 403 usually means an org or package permission problem, not just a missing token.