Next.js does not need a serverless platform: next build produces a Node.js server that runs on any Linux machine. With the standalone output mode, the build contains only the files the server needs, which keeps deployments small and fast to start. In this tutorial you will install Node.js 24 on Ubuntu 24.04, build a Next.js application in standalone mode, run it as a systemd service, let Nginx serve the hashed static assets with long cache headers and enable HTTPS with Let's Encrypt.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM. Production builds of larger apps can need more; add swap if the build is killed.
- A non-root user with
sudoprivileges, calledyour_userin this guide. - A domain name (
your_domain) with an A record pointing to the server's public IP. - UFW enabled with SSH allowed (
sudo ufw allow OpenSSH && sudo ufw enable).
Step 1 - Installing Node.js 24
Ubuntu's own nodejs package is too old for current Next.js releases, which need Node.js 20.9 or later. Install the Node.js 24 LTS line from the NodeSource repository. Download the setup script first so you can review it before running it:
cd ~
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
less nodesource_setup.sh
The script adds the NodeSource APT repository with its signing key. Run it and install Node.js, which includes npm:
sudo bash nodesource_setup.sh
sudo apt install nodejs
Verify the versions:
node -v
npm -v
v24.8.0
11.6.0
Step 2 - Getting the application onto the server
Create the application directory and give your user ownership of it:
sudo mkdir -p /var/www/myapp
sudo chown your_user:your_user /var/www/myapp
cd /var/www/myapp
If you have an existing project, clone it and install exactly the dependencies in its lockfile:
git clone https://github.com/your_user/myapp.git .
npm ci
To follow along with a new project instead, create one with the official starter. Accept the default answers to the prompts:
npx create-next-app@latest .
Step 3 - Enabling standalone output
In standalone mode, next build traces which files in node_modules the server actually imports and copies them, along with a minimal server.js, into .next/standalone. You no longer need the full node_modules directory at runtime.
Open the Next.js configuration file. New projects use next.config.ts; older ones may have next.config.js or next.config.mjs:
nano next.config.ts
Add the output option:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
Variables prefixed with NEXT_PUBLIC_ are inlined into the browser bundle when you build, so they must exist at build time. Put them in .env.production in the project root:
nano .env.production
NEXT_PUBLIC_SITE_URL=https://your_domain
Server-only secrets such as API keys or database URLs are read when the server runs, and you will pass them through systemd in Step 4 instead of storing them in the project.
Build the application:
npm run build
At the end of the build, Next.js prints every route with a symbol: ○ (Static) means the page was prerendered to HTML at build time and is served without running any code, while ƒ (Dynamic) means it is rendered on each request. This is the most useful optimization check you have: pages you expect to be static should show ○.
The standalone folder does not include public/ or the compiled assets in .next/static, because they are usually served by a CDN or web server. Copy them in so the Node.js server can also serve them:
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
Test the server in the foreground on localhost:
PORT=3000 HOSTNAME=127.0.0.1 node .next/standalone/server.js
▲ Next.js 16.0.3
- Local: http://127.0.0.1:3000
✓ Ready in 85ms
From a second SSH session, request the home page:
curl -I http://127.0.0.1:3000
HTTP/1.1 200 OK
X-Powered-By: Next.js
Content-Type: text/html; charset=utf-8
Stop the server with CTRL+C.
Step 4 - Running Next.js as a systemd service
Store runtime secrets in a file only root can read. Create it even if you have no secrets yet:
sudo nano /etc/myapp.env
# Server-side variables, for example:
# DATABASE_URL=postgresql://user:password@localhost:5432/myapp
sudo chmod 600 /etc/myapp.env
Create the unit file:
sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=Next.js app myapp
After=network.target
[Service]
Type=simple
User=your_user
Group=your_user
WorkingDirectory=/var/www/myapp/.next/standalone
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=HOSTNAME=127.0.0.1
EnvironmentFile=/etc/myapp.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
HOSTNAME=127.0.0.1 makes the server listen only on localhost, so traffic can reach it only through Nginx. The service runs as your user because Next.js writes its data cache and optimized images to .next/cache inside the standalone directory.
Start the service and enable it at boot:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp --no-pager
● myapp.service - Next.js app myapp
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
Active: active (running) since Thu 2026-09-24 11:02:17 UTC; 3s ago
Logs from the server, including console.log output from server code, are in the journal: sudo journalctl -u myapp -f.
NoteYou do not need a process manager such as PM2 here. systemd already restarts the app when it crashes, starts it at boot and collects its logs.
Step 5 - Configuring Nginx
Files under /_next/static/ have a content hash in their names, so they never change and can be cached by browsers for a year. Nginx can serve them directly from disk, which is faster than passing them through Node.js. Install Nginx and create a server block:
sudo apt install nginx
sudo nano /etc/nginx/sites-available/myapp
server {
listen 80;
listen [::]:80;
server_name your_domain;
gzip_types text/css application/javascript application/json image/svg+xml;
location /_next/static/ {
alias /var/www/myapp/.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;
}
}
Ubuntu's nginx.conf already turns gzip on; the gzip_types line extends compression to CSS and JavaScript. Pages rendered by Next.js are compressed by the Node.js server itself.
Enable the site, test the configuration and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
Check that Nginx serves a static chunk with the long cache header. Pick any file name from ls /var/www/myapp/.next/static/chunks:
curl -I http://your_domain/_next/static/chunks/your_chunk_file.js
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
TipNginx buffers proxied responses by default, which can delay pages that stream content with React Suspense. If you rely on streaming, add
proxy_buffering off;to thelocation /block.
Step 6 - Enabling HTTPS
Install Certbot with its Nginx plugin and request a certificate. Certbot adds the certificate to the server block and redirects HTTP to HTTPS:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d your_domain
A systemd timer renews the certificate automatically. Test it:
sudo certbot renew --dry-run
Open https://your_domain in a browser to confirm the site loads with a valid certificate.
Step 7 - Deploying updates
Each release needs a fresh build, the static files copied into the standalone folder and a restart. Because these steps must always run together, a short deploy script is worth having:
nano /var/www/myapp/deploy.sh
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/myapp
git pull --ff-only
npm ci
npm run build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
sudo systemctl restart myapp
Make it executable and run it whenever you want to release:
chmod +x /var/www/myapp/deploy.sh
/var/www/myapp/deploy.sh
Because set -e stops the script at the first error, a failed build never reaches the restart step. Note that next build replaces the .next directory while the old server is still running, so requests can fail for a few seconds until the restart completes. For zero-downtime releases, build into a separate directory and switch a symlink.
Troubleshooting
502 Bad Gateway: the Node.js server is not running. Check sudo journalctl -u myapp -n 50 --no-pager. Cannot find module errors usually mean the build failed or output: "standalone" is missing from the config.
Pages load without styles or scripts (404 on /_next/static/): the static files were not copied or the Nginx alias path is wrong. Confirm that /var/www/myapp/.next/static exists and that the alias ends with a slash.
A NEXT_PUBLIC_ variable is empty in the browser: it was not set when you ran npm run build. Add it to .env.production and rebuild; restarting alone is not enough.
The build is killed or exits with JavaScript heap out of memory: the server does not have enough RAM for the build. Add a swap file, or build in CI and copy the .next/standalone folder to the server.
Conclusion
Your Next.js application now runs as a standalone Node.js server under systemd, sits behind Nginx with year-long caching for hashed assets, and is served over HTTPS. Releases are a single script run.
As next steps you can:
- Review the build output and move pages that do not need per-request data to static rendering or incremental static regeneration.
- Build the app in a CI pipeline and ship only the standalone folder, so production never needs the full source tree.
- Put a CDN in front of the server to cache
/_next/static/and images closer to your visitors.
