8 min read

Zero-downtime deployments with Laravel Forge, Nuxt and PM2 on Nginx

Zak

I have a project that's using Nuxt 3 with the "static" build, that I used to deploy on a cheap VPS which was serving the static files.

It's a nice setup, very SEO-friendly and blazing-fast, although I wanted to leverage some of Nuxt's SSR features and streamline the deployments process.

npx nuxi generate is taking a lot of time, because the project has a few thousand pages, as it leverages programmatic SEO for a big chunk of pages.

So I decided to go with Nuxt SSR capabilities, deploying the project on the VPS, using Laravel Forge and Nginx as a reverse proxy.

Soon enough I was all set up, except for a little annoying bug: each time I deployed the project, it would go down for around a minute, and if a visitor hit the webserver they would get a cryptic error:

[ERROR 500] Cannot find imported module ...

I was very annoyed by this bug: I was pretty sure that using PM2 to run the Nuxt app continuously shouldn't take it down while it was being rebuilt.

On the other hand I could see where the problem was: .output/server/index.mjs was replaced with its imports while the project was building, so the server could not find some of the modules.

How do I fix the problem - I asked myself.

Then I got the idea: let's do it similarly to how Envoyer does 0-downtime deployments (the so-called "capistrano-like" deployment flow).

The benefits

Such setup has two big benefits:

  • 0-downtime deployments (the app must not go down while it's being deployed)
  • ability to rollback to a previous version seamlessly

So let's see how to achieve both of them.

How to set up Nginx as a reverse proxy for Nuxt SSR on Laravel Forge

If you haven't - create a new site on Laravel Forge's dashboard. You may pick "Static HTML" for the project type.

I highly suggest that you select the "isolation" option and make Forge create a dedicated user for your project.

Pick the Github repository that is hosting your Nuxt app, and select the correct branch.

Then, setup SSL, and once you're done, navigate to your site's dashboard on Forge and click "Edit files" -> "Edit Nginx configuration".

Then, before server block add:

map $sent_http_content_type $expires { "text/html" epoch; "text/html; charset=utf-8" epoch; default off; }

Then, in the server block, after error_page add:

location / { expires $expires; proxy_redirect off; 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; proxy_read_timeout 1m; proxy_connect_timeout 1m; proxy_pass http://localhost:3000; # take note of the port: it's the one on which you Nuxt instance will be running }

If you navigate to your website now, you'll probably get a 502 Bad gateway error, that's fine, we haven't started you Nuxt app yet.

In order to do so, SSH into your VPS and run:

npm ci # it installs your npm packages
npx nuxi build # it build the Nuxt app
node .output/server/index.mjs

Then, without closing your SSH session, navigate to your website, and you should see it running.

BUT there is a problem: as soon as you close your SSH session, the node process will be killed and you'll get the 502 Bad gateway error again.

Make your Nuxt app persistent using PM2

In order to make your Nuxt app long-lived, we can leverage PM2, a process manager for Node.js. It keeps your application online and restarts it if it crashes.

Luckily Forge already has pm2 installed out of the box, so we can go on with setting it up to make our Nuxt app persistent.

SSH into your VPS, and in your HOME directory (not in your project directory!), create an ecosystem.config.js file:

cd ~
nano ecosystem.config.js

And then, create the configuration:

module.exports = {
    apps: [
        {
            name: 'YOUR_PROJECT_NAME',
            port: '3000', // SAME PORT YOU CHOSE WHEN YOU EDITED YOUR NGINX CONFIGURATION IN THE ABOVE STEPS
            exec_mode: 'cluster',
            instances: '2', // 2 IS USUALLY FINE FOR SMALL TO MEDIUM PROJECTS
            script: '.output/server/index.mjs',
            listen_timeout: '3000',
        }
    ]
}

Save the file and close the editor CTRL + X.

Now you can start your app with:

cd ~/YOUR_PROJECT_FOLDER
pm2 startOrReload ~/ecosystem.config.js

If you close your SSH session and navigate to your website, you'll see that it'll be working fine again.

The deployment script

Now, the problem is that we don't have a deployment script yet, so if you push your changes to Github, they won't be live on until you SSH in again and do npm ci && npm nuxi build: pretty tedious to do each time, isn't it?

Open your Forge dashboard and navigate to your site panel. Then click on Deployments on the left sidebar.

Then, in the Deployment Script editor, update your script (replace YOUR_PROJECT_FOLDER accordingly):

cd YOUR_PROJECT_FOLDER # replace me
git pull origin $FORGE_SITE_BRANCH

npm ci
npm run build

pm2 startOrReload ~/ecosystem.config.js

Save the script by clicking on "Update". Make some changes to your project, commit them to version control and push them on Github. Then go back on Forge and hit "Deploy Now".

You should see your changes live on your website after a couple of minutes, depending on how fast the VPS is and how big your project is.

You can now click on Enable Quick Deploy button on Forge: each time you push changes to your project, it will automatically pick them and publish them live!

But, if it takes so much time, it's very likely that you'll hit the 500 Error if you visit your website while it's being deployed.

Let's see how to fix that

Nuxt 0-downtime deployments on Laravel Forge with PM2

To achieve zero downtime deployments I came up with a pretty simple solution:

  • build the project
  • copy the built files into a separate directory
  • symlink the new directory to a current folder
  • make pm2 serve files from the current folder

Before we start, we must add the before mentioned current folder in our project's .gitignore file to avoid conflict when the deployment script pulls from Github. So add the folder, commit the changes to git and deploy on Forge.

Once you've done that, let's update our ecosystem.config.js file, by changing the script value:

module.exports = {
    // ....
    script: 'current/server/index.mjs',
    // ....
}

Save the file and exit.

Now we must update our deployment script to create the symlink. Go to your Deployments panel on Laravel Forge, and update the deployment script:

cd YOUR_PROJECT_FOLDER # replace me
git pull origin $FORGE_SITE_BRANCH

npm ci
npm run build
ln -snf .output current # symlink the built files

pm2 startOrReload ~/ecosystem.config.js

Click Update and Deploy now.

To make PM2 pick the changes, you must kill the process and restart it.

pm2 delete YOUR_PROJECT_NAME # Remember to change this to what you set in your ecosystem.config.js file ...
cd YOUR_PROJECT_FOLDER
pm2 startOrReload ~/ecosystem.config.js

Now, each time you deploy, there won't be any downtimes in your app! 🚀

Bonus: release versioning and rolling back app versions

Now that everything is set up, we can easily implement a dead-simple app versioning system, to be able to rollback to an older deployment in case a deployment you do has bugs.

Here are the steps:

  • when the app is built, copy the .output content into a release folder
  • symlink the release folder to current

A simple way of versioning releases is to use the git SHA1 hash and the timestamp when the deployment is being executed. That way, we'll have different folders that look something like this:

release-2c0017a6349165f662a987b1c16237908c80a506-2024-02-27-07-53-10 release-e33162798d2c2b1636a96d4c5eb0a74159467ce7-2024-02-28-07-30-51 release-eddbea1836e41b43db1628a6bc8f3acb4d10f7be-2024-02-28-07-24-52

To avoid issues with git conflicts, add the release folders pattern to your project's .gitignore file:

release-*

Make a new release folder on each git push

Let's see how to do that, by editing our deployment script on Forge:

cd YOUR_PROJECT_FOLDER # replace me
git pull origin $FORGE_SITE_BRANCH

npm ci
npm run build

# Create a release folder
folder=release-$(git rev-parse HEAD)-$(date '+%Y-%m-%d-%H-%M-%S')

# Copy built files into the release folder
cp -r .output "${folder}"

# Symlink the built files
ln -snf .output current 

pm2 startOrReload ~/ecosystem.config.js

Rolling back a release

If you ever need to roll back a release, you can just SSH into your VPS and change the symlink to your current folder and reload pm2:

lns -snf MY_OLD_RELEASE current
pm2 startOrReload ~/ecosystem.config.js

Don't run out of disk space!

But we're not quite done yet! We will end up with a bunch of release folders, and we can potentially run out of disk space after some time. Let's fix it by removing old releases.

In my case, I only keep the last 5 releases, just in case I need to roll back to a future release.

Let's see how to automatically clean up old release folders to avoid cluttering our VPS disk space.

It's as easy as updating your deployment script:

cd YOUR_PROJECT_FOLDER # replace me
git pull origin $FORGE_SITE_BRANCH

npm ci
npm run build

# Create a release folder
folder=release-$(git rev-parse HEAD)-$(date '+%Y-%m-%d-%H-%M-%S')

# Copy built files into the release folder
cp -r .output "${folder}"

# Symlink the built files
ln -snf .output current

# Cleanup old releases
for old in `ls -d release-* | grep -v ${folder} | head -n-5`; do
    rm -rf "${old}"/
done

pm2 startOrReload ~/ecosystem.config.js

If you want to keep more (or less) releases, just change head -n-5 to a different number (eg: to keep last 10 releases, you'd do head -n-10).

Conclusion

In this blog post I showed you how to easily do zero downtime deployments of a Nuxt SSR app on a VPS on Laravel Forge, using Nginx as a reverse proxy and PM2 as a process manager.

I also showed you how to do simple release versioning and how to roll back to a previous app version if something breaks.

I hope you enjoyed the article, might you have any question feel free to contact me on twitter/X @thugic or drop me an email on hello@zako.dev