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 thecurrent
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 arelease
folder - symlink the
release
folder tocurrent
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