TallmanCode Logo

Deploy Nuxt 3 or 4 to a VPS with GitHub Actions and PM2

Author TallmanCode

Categories Vue.js

hero-deploy-nuxt-vps-github-actions-pm2.webp

Hey there, fellow coders! Today we're tackling an alternative way to deploy your Nuxt app. Instead of building your project on the VPS, we'll build everything on the GitHub Actions runner and transfer the finished output to the server with scp. That means no build step eating your server's CPU and memory, which really matters on a smaller VPS.

Sound good? Let's get started!

A Quick Note on Nuxt Versions

This article started life as a Nuxt 3 guide, and it's worth knowing where things stand today. Nuxt 3 reached end of life on July 31, 2026, so it no longer receives bug fixes or security patches. Nuxt 4 has been the stable major version since July 2025, and the Nuxt team has said the upgrade from 3 to 4 is smooth for most projects.

The good news is that the deployment approach below works the same way for both. Nuxt builds a Node server into an .output folder by default, and that's what we're shipping. If you're still on Nuxt 3, this guide will get you deployed, and it's a good moment to plan your upgrade too.

What You'll Need

Before we dive in, make sure you have:

  • A VPS running Ubuntu, with SSH access
  • A recent LTS release of Node.js on the VPS (Nuxt 4 needs Node 20 or newer, and Nuxt 3 needs 18 or newer)
  • PM2 installed globally on the VPS
  • A GitHub repository with your Nuxt project and a package-lock.json
  • A dedicated SSH key pair for deployments (we'll create one in Step 2)

Notice what's not on the list: a build toolchain on the server. The Nuxt docs describe the build result as a ready-to-run Node server, and for most apps that folder is all the VPS needs.

Step 1: Prepare the VPS

First, SSH into your VPS and create a directory for the app. We'll also hand ownership to the user that GitHub Actions will connect as. This part matters, because scp runs as that user, and a root-owned folder would leave you staring at a "permission denied" error.

bash
sudo mkdir -p /var/www/nuxt-app
sudo chown $USER:$USER /var/www/nuxt-app

Next up is Node.js. Here's a gotcha that trips up a lot of people: the default apt install nodejs on Ubuntu often gives you a very old version. It's Node 12 on Ubuntu 22.04 and Node 18 on 24.04, which is too old for a current Nuxt setup. Instead, install from NodeSource (or use nvm if you prefer managing versions per user):

bash
sudo apt update
sudo apt install -y ca-certificates curl gnupg
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node --version

Then install PM2 globally:

bash
sudo npm install -g pm2

One tip before we move on: keep the same Node major version on the VPS and in your workflow. Nuxt recommends even-numbered releases such as 20, 22, or 24, and we'll use 24 in the examples.

Step 2: Create a Deploy Key

In the original version of this article, the prerequisites mentioned an SSH key "between GitHub and your VPS," which was a bit vague. What we actually need is a key that lets GitHub Actions log in to your server. A dedicated key for this job is a good idea, because you can revoke it without touching your personal access.

On your local machine, generate one:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/nuxt_deploy

Then copy the public key to your VPS:

bash
ssh-copy-id -i ~/.ssh/nuxt_deploy.pub your-user@your-vps-ip

The private key (~/.ssh/nuxt_deploy) is the one we'll store in GitHub in the next step. The scp action's own documentation recommends key authentication over passwords, keeping credentials in GitHub Secrets, and rotating deployment keys regularly, so this setup follows that advice.

Step 3: Set Up GitHub Secrets

In your repository, head to Settings, then Secrets and variables, then Actions, and add these repository secrets:

  • VPS_HOST: your VPS IP address or hostname
  • VPS_USER: the SSH username on your VPS
  • VPS_SSH_KEY: the full contents of the private key, including the BEGIN and END lines
  • VPS_PORT: the SSH port (22 unless you've changed it)

Step 4: Add a PM2 Ecosystem File

Rather than starting PM2 with a long command, we'll describe the app in a config file. The Nuxt docs recommend this approach, and they use the .cjs extension so it works even when your project is set up as an ES module.

Create ecosystem.config.cjs in the root of your repository:

js
module.exports = {
apps: [
{
name: 'nuxt-app',
port: '3000',
exec_mode: 'cluster',
instances: 'max',
script: './.output/server/index.mjs'
}
]
}

Here's the cool part about cluster mode: when we reload the app after each deploy, PM2 starts the new workers first and only retires the old ones once the new ones are ready. That gives us a zero-downtime reload. One thing to keep in mind is that instances: 'max' runs one process per CPU core, so on a small VPS you may want to set it to 1 or 2 instead.

Step 5: Create the GitHub Actions Workflow

Now for the main event. In your repository, create .github/workflows/deploy.yml:

yml
name: Deploy to VPS
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Nuxt app
run: npm run build
- name: Archive build output
run: tar -czf deploy.tar.gz .output ecosystem.config.cjs
- name: Copy archive to VPS
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: deploy.tar.gz
target: /var/www/nuxt-app/
- name: Extract files and reload app
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
script: |
cd /var/www/nuxt-app
tar -xzf deploy.tar.gz
rm deploy.tar.gz
pm2 startOrReload ecosystem.config.cjs

A quick word on the action versions. GitHub Actions move quickly, and the original workflow used versions that are now well out of date, including actions/checkout@v2. The versions above are the current major releases at the time of writing, but it's worth checking the Marketplace or letting Dependabot keep them fresh for you.

Step 6: What the Workflow Actually Does

Let's take a closer look at each piece:

  • Checkout repository: pulls the latest code from your main branch onto the runner.
  • Set up Node.js: installs the Node version we chose, and caches npm downloads to speed up future runs. Pinning the version is better than relying on whatever the runner happens to have.
  • Install dependencies: npm ci installs exactly what's in your lockfile, which makes it the better fit for CI than npm install.
  • Build Nuxt app: npm run build produces the production server in the .output folder. The original article described this as generating static files, but that's actually what nuxt generate does. A regular build gives you a Node server.
  • Archive build output: bundles .output and the PM2 config into a single deploy.tar.gz for a quick transfer.
  • Copy archive to VPS: the scp action securely copies the archive into /var/www/nuxt-app/.
  • Extract files and reload app: the SSH action unpacks the archive, removes it, and runs pm2 startOrReload. According to the PM2 docs, that command starts the app if it isn't running and does a zero-downtime reload if it is, which makes it a great fit for CI/CD.

That last point also solves a chicken-and-egg problem from the original setup, where you had to start PM2 by hand before your first deploy. Now the first run starts the app for you.

Step 7: Deploy!

You're all set. Commit your workflow and ecosystem file, then push to main:

bash
git add .
git commit -m "Add GitHub Actions deployment"
git push origin main

Open the Actions tab in your repository and watch the build logs roll in. If everything runs smoothly, your app will be live on your VPS.

Step 8: Keep It Running After a Reboot

Once your first deploy has finished and the app is running under PM2, SSH into the VPS and run:

bash
pm2 startup
pm2 save

The pm2 startup command generates a startup script so PM2 comes back after a reboot, and it prints a command for you to copy and run. Run pm2 startup as the same user that runs PM2. Then pm2 save stores your current process list so PM2 knows what to bring back. The order matters here: save only after the app is running, otherwise there's nothing to remember.

Where to Go From Here

You now have a working pipeline, and there are a few natural next steps:

  • Add a reverse proxy. Nuxt's production server listens on port 3000 by default, and the docs recommend running it behind something like Nginx or Cloudflare, which can also handle SSL.
  • Handle environment variables. Your .env file isn't part of the archive, so set runtime values on the server or in your PM2 config. Nuxt lets you override runtimeConfig values with NUXT_ prefixed environment variables.
  • Watch for native modules. The build happens on a Linux x64 runner, so if your app depends on native packages, make sure your VPS matches that environment.
  • Consider versioned releases. Extracting over the previous build works well, but old hashed files can pile up over time. Deploying into timestamped release folders, with a symlink to the current one, gives you cleaner rollbacks.

Wrapping Up

This approach is handy when you want to build on the GitHub Actions runner and transfer the artifacts to your VPS without building on the server itself. Only the files you need get sent over, which keeps the load on your VPS low, and PM2 keeps your app running and reloads it smoothly on every deploy.

That's it for today! With this setup, you can push to main and let the pipeline take it from there. Keep automating, keep coding, and until next time!