
How to Deploy Nuxt 4 on an Ubuntu VPS with Nginx and PM2
Deploying a Nuxt 4 application to an Ubuntu VPS gives you full control over performance, environment variables, server-side rendering, and operating costs. However, a production deployment involves more than running npm run dev. You need a repeatable build process, a process manager, a reverse proxy, HTTPS, and a safe update strategy.
This guide explains the setup I recommend for a self-hosted Nuxt application: Nitro running as a Node.js server, PM2 keeping the process alive, and Nginx handling public traffic. It also covers the issues that commonly appear on small VPS instances, including failed builds, memory pressure, inaccessible uploaded media, and deployments that disappear after a reboot.
Production architecture at a glance
A simple Nuxt VPS deployment has three layers:
Nuxt and Nitro render the application and expose the production Node.js server.
PM2 runs that server continuously, restarts it after a crash, and restores it after a reboot.
Nginx receives requests on ports 80 and 443, terminates HTTPS, and proxies traffic to the private Nuxt port.
The browser should never connect directly to the Nitro port. Keep the application bound to localhost and let Nginx become the only public entry point.
Prerequisites
Before starting, prepare an Ubuntu VPS with a domain name pointing to its public IP address. You will also need:
SSH access with a non-root user that can run
sudo.A supported Node.js release and npm.
Git, Nginx, and PM2.
Access to the application repository.
A documented list of production environment variables.
Keep secrets out of the Git repository. API keys, database credentials, and private tokens belong in server-side environment configuration, not in committed source files.
1. Prepare the Ubuntu server
Update the package index and install the basic packages:
sudo apt update && sudo apt upgrade -y
sudo apt install -y git nginx
Install Node.js using the method approved for your infrastructure. After installation, verify the active versions:
node --version and npm --version
For a production server, consistency matters more than chasing every new release. Pin the Node.js major version used by your project and test upgrades before applying them to the live server.
2. Clone the project and install dependencies
I prefer keeping application code under a predictable path such as /var/www/apps/my-nuxt-app. Clone the repository, enter the directory, and install dependencies from the lockfile:
git clone YOUR_REPOSITORY_URL /var/www/apps/my-nuxt-app
cd /var/www/apps/my-nuxt-app
npm ci
Use npm ci on the server when a valid lockfile exists. It produces a more deterministic installation than a general npm install and fails clearly when the lockfile and package manifest disagree.
3. Configure production environment variables
Nuxt can read runtime configuration from environment variables. Public values must be intentionally exposed through your Nuxt runtime configuration, while private values should remain server-only.
A typical application that consumes an external API may need variables such as an API base URL, site URL, or server-side token. Verify three things before building:
The production API URL uses HTTPS.
The backend allows requests from the portfolio domain when CORS applies.
Media URLs returned by the API are public URLs, not local filesystem paths.
A file existing on the VPS does not automatically make it accessible on the web. The backend or Nginx must map its storage directory to a public URL, and the returned API value must point to that URL.
4. Build the Nuxt application for production
Run the production build from the project directory:
npm run build
With the Node server preset, a successful build produces a Nitro server entry point at .output/server/index.mjs. Confirm that this file exists before starting PM2.
Some Vite or plugin messages about source maps are warnings rather than fatal errors. Do not diagnose the deployment from a warning alone. Check the command exit code, the final Nitro output, and whether .output/server/index.mjs was created.
Building on a low-memory VPS
Nuxt builds can temporarily use much more memory than the running application. If the process is killed or the terminal freezes, check available memory and competing services first:
free -h
ps aux --sort=-%mem | head
On a constrained server, a measured Node heap limit can make the build more predictable:
NODE_OPTIONS="--max-old-space-size=768" npm run build
The correct value depends on total RAM and other services such as MySQL. A larger heap is not automatically better: setting it too close to total memory can trigger the Linux out-of-memory killer. If production builds remain unreliable, build in CI and deploy the tested artifact instead of forcing every build onto the VPS.
5. Run Nitro with PM2
Install PM2 globally, then start the generated Nitro server:
sudo npm install -g pm2
PORT=3000 HOST=127.0.0.1 NODE_ENV=production pm2 start .output/server/index.mjs --name my-nuxt-app
Check its status and logs:
pm2 status
pm2 logs my-nuxt-app --lines 100
Once the process is healthy, configure PM2 to restore it after a reboot:
pm2 startup
Run the command printed by PM2, then save the current process list:
pm2 save
This step is easy to miss. A site can run correctly for weeks and then stay offline after maintenance because the process list was never saved.
6. Configure Nginx as a reverse proxy
Create an Nginx server block for your domain. The essential location forwards requests to the private Nitro server:
proxy_pass http://127.0.0.1:3000;
Forward the original host and client information as well:
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;
Test the configuration before reloading it:
sudo nginx -t
sudo systemctl reload nginx
If Nginx returns a 502 Bad Gateway response, confirm that PM2 shows the application as online, the port matches, and the server is listening on 127.0.0.1.
7. Enable HTTPS
Use a trusted certificate workflow that supports automatic renewal, then redirect HTTP traffic to HTTPS. After enabling TLS, test both the main domain and any API or media subdomain used by the application.
Do not stop after seeing the padlock in one browser. Confirm the certificate chain, renewal timer, Nginx configuration, and application URLs. Mixed-content errors can still break images or API calls when the page is HTTPS but a resource URL uses HTTP.
8. Use a safe deployment update workflow
A repeatable update should be short and observable:
Pull the intended branch or release tag.
Run
npm ci.Build the application and stop if the build fails.
Reload the PM2 process only after a valid output exists.
Check logs and perform a quick public health check.
A basic sequence looks like this:
git pull --ff-only
npm ci && npm run build
pm2 reload my-nuxt-app --update-env
pm2 save
Never replace a working process with a broken build. For higher-traffic systems, keep release directories and switch a symlink only after the new release passes a health check.
Common deployment problems
The build succeeds, but the site is unavailable
Check the PM2 process, application logs, listening port, Nginx upstream, firewall rules, and domain DNS. Diagnose the request path layer by layer instead of changing several configurations at once.
Uploaded images exist on disk but return 404
This is usually a storage exposure problem, not a Nuxt rendering problem. Verify file permissions, the backend public-storage mapping, the Nginx location or alias, and the exact media URL returned by the API. If Nuxt and the API run as different processes, their local directories are not automatically shared.
The application disappears after reboot
Run pm2 startup, execute the generated system command, and run pm2 save again. Also confirm that the PM2 service belongs to the same Linux user that owns the process list.
The VPS becomes unresponsive during builds
Inspect memory usage, reduce unnecessary concurrent services, use a reasonable heap limit, or move builds to CI. Adding swap can provide a safety buffer, but it is not a substitute for adequate memory and will make builds slower.
Production checklist
The project installs from a committed lockfile.
Secrets are not stored in Git.
.output/server/index.mjsexists after the build.PM2 shows the Nuxt process as online.
PM2 startup and process persistence are configured.
Nginx proxies only to the local application port.
HTTPS and automatic renewal are verified.
API and media URLs use the correct public HTTPS domain.
Logs are monitored and rotated.
The update procedure includes a health check and rollback plan.
Final thoughts
Nuxt 4 works well on a modest Ubuntu VPS when each layer has a clear responsibility. Nitro serves the application, PM2 manages its lifecycle, and Nginx handles public traffic and TLS. Most deployment failures happen at the boundaries between those layers: a mismatched port, an unsaved PM2 process list, a private media path, or a build competing for limited memory.
Start with the smallest reliable architecture, document every environment variable, and make deployments repeatable. That discipline matters more than adding infrastructure before the application actually needs it.