Deploying Next.js on a VPS without Vercel and without Docker comes down to three things: build the project in standalone mode, run the resulting server.js as a systemd service, and put Nginx with HTTPS in front of it. As a result, the application runs as a separate unprivileged user, comes back up on its own after a crash or reboot, and Nginx serves the static files without loading Node.js. Below are all the steps with checks, memory measurements and a short Docker variant.
Tested on Ubuntu 24.04 and 26.04 with Next.js 16.4.0, Node.js 24.21.0 and Nginx from the Ubuntu repositories (1.24 and 1.28). The HTTPS step requires a real domain, so in the test environment only the certbot installation was tested, and the certificate commands are taken from the certbot documentation. Replace 203.0.113.10 with your server's address and example.com with your domain.
Prerequisites
- A server running Ubuntu 24.04 or 26.04, a sudo user, and ports 80 and 443 open; see initial server setup.
- A domain whose A record points to the server's address (required for HTTPS).
- A Next.js project. For testing we used the starter:
npx create-next-app@latest myapp --yes.
How many resources you need
Measurements on the Next.js 16.4.0 starter project:
| What | Memory |
|---|---|
Running server.js right after startup |
about 60 MB |
| The same after 2,000 page requests | about 170 MB |
The npm run build build (all processes combined) |
up to 0.9 GB |
A running application fits on a server with 1 GB of RAM, as long as there is no heavy database on it. The build needs noticeably more: in a test environment short on memory it was cut off three times with the message Killed. So on a server with 1–2 GB, either add a swap file (how, see the initial server setup article), or build the project in GitHub Actions or on your own computer and upload the finished build. A real project with dozens of pages and dependencies will have higher numbers; measure your own.
Step 1. Install Node.js 24 LTS
Next.js 16 requires Node.js 20.9 or later. Node.js 20 stopped receiving updates in April 2026, so install the current LTS version, 24 (updates until April 2028). The Ubuntu 24.04 repository has Node.js 18, which no longer works for Next.js 16; Ubuntu 26.04 has Node.js 22, which works but is supported only until April 2027. Install Node.js 24 from the NodeSource repository:
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt install -y nodejs
node -v
The last command should print v24.… (we got v24.21.0). From then on, Node.js is updated together with the system via apt.
Step 2. Build the project in standalone mode
In the regular mode, running next start on the server requires the entire node_modules. In standalone mode the build itself picks only the files it needs and creates a ready-to-run server.js. Enable it in next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
If the file already has other settings, just add the line output: "standalone",. Build the project and copy the static files over; according to the Next.js documentation, the build does not put them into standalone:
npm ci
npm run build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
Check: .next/standalone contains server.js, package.json, node_modules, public and .next with a static directory inside. For the starter project, all of it takes about 40 MB.
Two things to know about the build:
NEXT_PUBLIC_…variables are inlined into the code at build time. If you need them in the browser, set them beforenpm run build, not in the environment file on the server. Other variables are read at startup.- The build is tied to the platform.
node_modulesincludes thesharpimage processing module for the builder's operating system and processor. Build on Linux with the same architecture as the server (x86-64 or ARM): a build made on macOS or Windows will not be able to process images on a Linux server. The GitHub runnerubuntu-24.04works for a regular x86-64 server.
Step 3. A user and directory on the server
The application will run as the system user nextjs with no password and no login:
sudo useradd --system --home-dir /srv/nextjs --shell /usr/sbin/nologin nextjs
sudo install -d -o nextjs -g nextjs -m 755 /srv/nextjs /srv/nextjs/app
Upload the build. If you built it on your computer or in CI, first upload it to a temporary directory on the server (admin is your sudo user):
rsync -az --delete .next/standalone/ admin@203.0.113.10:nextjs-build/
Then, on the server, move it into place with nextjs as the owner:
sudo rsync -a --delete --chown=nextjs:nextjs ~/nextjs-build/ /srv/nextjs/app/
If you built directly on the server, the second command is enough, with the path to .next/standalone/ instead of ~/nextjs-build/. If rsync is not found, install it: sudo apt install -y rsync.
Step 4. Environment variables
Keep secrets such as the database URL and API keys out of the repository, in a file that only the service can read:
sudo install -d -m 750 -o root -g nextjs /etc/nextjs
sudo tee /etc/nextjs/env > /dev/null <<'EOF'
# App secrets and settings, read at startup
DATABASE_URL=postgres://app:secret@127.0.0.1:5432/app
EOF
sudo chown root:nextjs /etc/nextjs/env
sudo chmod 640 /etc/nextjs/env
The file is owned by root, the nextjs group can only read it, and other users on the server cannot see it. If the database is not installed yet, see PostgreSQL on Ubuntu.
Step 5. A systemd service for Next.js
sudo tee /etc/systemd/system/nextjs.service > /dev/null <<'EOF'
[Unit]
Description=Next.js
After=network.target
[Service]
Type=simple
User=nextjs
Group=nextjs
WorkingDirectory=/srv/nextjs/app
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=HOSTNAME=127.0.0.1
EnvironmentFile=/etc/nextjs/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now nextjs
What matters here:
HOSTNAME=127.0.0.1:server.jslistens only on the local address, so from outside it can be reached only through Nginx. Without this line the server listens on all addresses, and port 3000 will be open to the internet unless the firewall blocks it.PORT=3000is the port Nginx will forward requests to.Restart=on-failurerestarts the application 5 seconds after a crash;enablestarts it after a server reboot.NoNewPrivileges,ProtectSystem,ProtectHome,PrivateTmpare systemd's built-in protections: the process cannot gain more privileges, cannot write to system directories and cannot see users' home directories.
Check:
systemctl status nextjs --no-pager
ss -ltnp | grep 3000
curl -sI http://127.0.0.1:3000/ | head -1
You should see Active: active (running), the address 127.0.0.1:3000 and HTTP/1.1 200 OK. The application's logs: journalctl -u nextjs -f.
Step 6. Nginx as a reverse proxy
Nginx accepts requests on ports 80 and 443, passes them to the application and serves the files from /_next/static/ itself:
sudo apt install -y nginx
sudo tee /etc/nginx/sites-available/nextjs > /dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# Build files: their names contain a hash, so they can be cached forever
location /_next/static/ {
alias /srv/nextjs/app/.next/static/;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/nextjs /etc/nginx/sites-enabled/nextjs
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
proxy_set_headerpasses the real domain, the visitor's address and the protocol to the application; without them Next.js would see every request as coming from127.0.0.1over HTTP./_next/static/holds the build's JS, CSS and fonts. Their names contain a content hash, so the browser can keep them for a year (immutable): after a new build the names change. Nginx serves these files from disk faster than Node.js.
Check from any computer:
curl -sI http://example.com/ | head -1
The response is HTTP/1.1 200 OK. Open the site in a browser and find any /_next/static/… file in the developer tools: it has the headers Server: nginx and Cache-Control: public, max-age=31536000, immutable.
Step 7. HTTPS with Let's Encrypt
A free Let's Encrypt certificate is issued and renewed by certbot. It adds the port 443 block and the HTTP-to-HTTPS redirect to the Nginx configuration by itself. The domain must already point to the server, and ports 80 and 443 must be open.
Installation following the certbot instructions:
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
sudo certbot --nginx -d example.com -d www.example.com
Certbot asks for an email address for notifications, asks you to accept the terms and issues the certificate. If the domain has no www, remove the second -d. Instead of snap, you can install the package from the Ubuntu repository: sudo apt install -y certbot python3-certbot-nginx (certbot 2.9 on Ubuntu 24.04, 4.0 on 26.04). It also includes the Nginx plugin and enables the certbot.timer renewal timer by itself.
Check: curl -sI https://example.com/ | head -1 responds with HTTP/1.1 200 OK, and a test renewal completes without errors:
sudo certbot renew --dry-run
The certificate is valid for 90 days, and certbot renews it automatically. The schedule is shown by systemctl list-timers | grep certbot.
How to update the application
A new version goes through the same steps: build, copy public and .next/static, upload and restart the service:
sudo rsync -a --delete --chown=nextjs:nextjs ~/nextjs-build/ /srv/nextjs/app/
sudo systemctl restart nextjs
During the restart, which took about a second in our test, Nginx responds with 502. To update the site automatically on push to main, check it after deployment and roll back in a second, use the setup from the article "Deploy to a VPS from GitHub Actions over SSH": releases live in separate directories, and the service points to the current symlink.
The Docker variant
If the server already has Docker, the same build can be packaged into an image. The Dockerfile in the project root:
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Next to it, put a .dockerignore with the lines node_modules, .next and .git. Build and run:
docker build -t myapp .
docker run -d --name myapp --restart unless-stopped \
-p 127.0.0.1:3000:3000 --env-file /etc/nextjs/env myapp
Inside the container the application listens on all addresses (HOSTNAME=0.0.0.0), otherwise Docker could not pass requests to it, while the port is published to the outside only on 127.0.0.1. The Nginx and certbot configuration is the same, except that you remove the location /_next/static/ block: there are no files on the server's disk, and Next.js serves the static files itself, with the same immutable header. The starter project's image takes about 200 MB, and the container uses about 100–150 MB of memory.
Troubleshooting
| Symptom | What to do |
|---|---|
502 Bad Gateway from Nginx |
The application is not running or has crashed: systemctl status nextjs and journalctl -u nextjs -n 50. Check that the port in proxy_pass matches PORT |
The page has no styles, /_next/static/… files return 404 |
.next/static was not copied into .next/standalone/.next/ before uploading. Repeat step 2 |
Images via next/image don't show |
The build was made on a different system or processor, and sharp does not fit the server. Build on Linux with the same architecture |
The site opens at http://203.0.113.10:3000 |
HOSTNAME=127.0.0.1 is not set in the service, and the port is open in the firewall |
A NEXT_PUBLIC_… variable is empty in the browser |
It was not set at build time. Set it before npm run build and rebuild |
The build stops with the message Killed |
Out of memory. Add a swap file or build in CI and upload the finished build |
certbot cannot validate the domain |
The domain's A record does not point to the server, or port 80 is closed. Run dig +short example.com on your computer and sudo ufw status on the server |
What's next
- Deploy to a VPS from GitHub Actions over SSH: automatic deployment with checks and rollback.
- Initial server setup on Ubuntu: firewall, security updates and swap before launching the site.
- PostgreSQL on Ubuntu: a database for the application on the same server.
- A Next.js build takes up to 1 GB of memory; how to choose a server size is covered in the article "How much RAM does a VPS need".
- Several sites on one server and certificate renewal (Let's Encrypt is moving to 64-day certificates): Nginx reverse proxy and certbot.
- Ways to install Node.js and upgrade to a new version: how to install Node.js on a server.