How to Self Host Next.js with PM2 and Nginx

Learn how to self host your Next.js app

Next.js is a wonderful frontend framework to create web applications. If you want to deploy your app and make it publicly available, you've got multiple options: Using a ready-made cloud service (like Vercel, Netlify), using any hosting provider that supports Node.js or completely self host your Next.js app on your own servers.

Hosting Next.js yourself has the great advantage that you are independent of a single provider. In addition, you know exactly where your app and data are hosted (important if, as an EU company, you want to host within the EU, for example). And if you're like me and always want to know exactly what's happening technically under the hood, this option is perfect for you.

So let's go through the options for hosting Next.js yourself.

Requirements

First, you need a virtual server with Linux to try these things out. I can recommend Hetzner, Digital Ocean or any other cloud provider with fast VPS. For the following examples we are using Ubuntu Linux Version 22.04, but they will work in other versions and Debian Linux too.

The server should have at least 1GB RAM, a fast CPU and at least 10GB hard drive space.

All of the following commands assume that you are logged in as a non-root user with sudo privileges. If you are logged in as root, just omit the sudo prefix or create a user and enable sudo, which is the safest method.

Node.js and NPM

Requirements regarding software for the current version of Next.js is Node.js Version 18.17 or later. You can check the current requirements here.

Connect to your VPS via ssh and let's get started with updating the system to the latest packages:

sudo apt update
sudo apt upgrade

Now we install Node.js and NPM, the Package Manager for Node.js.

sudo apt install nodejs npm

Verify the installation of Node with the following command

node -v

The output should be v12.22.9 or similar.

To meet the requirements of Next.js and get the latest security updates, we have to upgrade Node to the latest stable version. The Node n module can do it for us:

sudo npm cache clean --force # Clean the cache
sudo npm install -g n # Install latest node version. -g option stands for global
sudo n stable # switch to latest stable version
sudo hash -r # reset the location hash for the the node command location

Now if you run node -v you should see an output like v20.10.0. Perfect!

Checkout and build your app

Continue with cloning your project. First, we create the main directory in which your applications are located. If the directory already exists, you can safely skip this step.

mkdir ~/www
cd ~/www

Now we clone our app using git clone <url>. The URL is the clone URL of your Git repository containing the Next.js app. If you are not familiar with Git, you can check out my Git Tutorial on this blog. For this example, I use the Next.js App Router Playground from Vercel.

git clone https://github.com/vercel/app-playground.git

Change in your projects directory:

cd app-playground

Install the required packages for your app:

npm install

And build your app for production mode:

npm run build

You should see an output like this:

> build
> next build
▲ Next.js 14.0.3-canary.7
⚠ You are using an experimental edge runtime, the API might change.
✓ Creating an optimized production build
✓ Compiled successfully
Linting and checking validity of types
✓ Linting and checking validity of types
⚠ Using edge runtime on a page currently disables static generation for that page
✓ Collecting page data
Generating static pages (0/37) [= ]
✓ Generating static pages (37/37)
✓ Collecting build traces
✓ Finalizing page optimization
Route (app) Size First Load JS
┌ ○ / 188 B 91.5 kB
├ ○ /_not-found 0 B 0 B
├ ℇ /api/og 0 B 0 B
├ λ /api/revalidate 0 B 0 B
├ ○ /context 286 B 84.8 kB
├ λ /context/[categorySlug] 1.59 kB 86.1 kB
├ λ /context/[categorySlug]/[subCategorySlug] 1.59 kB 86.1 kB
├ ○ /error-handling 1.02 kB 85.6 kB
├ λ /error-handling/[categorySlug] 1.02 kB 85.6 kB
├ λ /error-handling/[categorySlug]/[subCategorySlug] 1.02 kB 85.6 kB
├ ○ /hooks 286 B 84.8 kB
├ λ /hooks/[categorySlug] 1.47 kB 86 kB
├ λ /hooks/[categorySlug]/[subCategorySlug] 1.47 kB 86 kB
├ ○ /isr 286 B 84.8 kB
├ ● /isr/[id] 1.62 kB 86.2 kB
├ ├ /isr/1
├ ├ /isr/2
├ └ /isr/3
├ ○ /layouts 287 B 84.8 kB
├ λ /layouts/[categorySlug] 287 B 84.8 kB
├ λ /layouts/[categorySlug]/[subCategorySlug] 287 B 84.8 kB
├ ○ /loading 287 B 84.8 kB
├ λ /loading/[categorySlug] 285 B 84.8 kB
├ ○ /not-found 188 B 91.5 kB
├ λ /not-found/[categorySlug] 287 B 84.8 kB
├ λ /not-found/[categorySlug]/[subCategorySlug] 286 B 84.8 kB
├ ○ /parallel-routes 287 B 84.8 kB
├ ○ /parallel-routes 287 B 84.8 kB
├ ○ /parallel-routes 287 B 84.8 kB
├ ○ /parallel-routes/demographics 287 B 84.8 kB
├ ○ /parallel-routes/impressions 285 B 84.8 kB
├ ○ /parallel-routes/subscribers 287 B 84.8 kB
├ ○ /parallel-routes/view-duration 285 B 84.8 kB
├ ○ /route-groups 287 B 84.8 kB
├ λ /route-groups/[categorySlug] 287 B 84.8 kB
├ λ /route-groups/[categorySlug]/[subCategorySlug] 287 B 84.8 kB
├ ○ /route-groups/blog 287 B 84.8 kB
├ ○ /route-groups/checkout 286 B 84.8 kB
├ ○ /snippets 188 B 91.5 kB
├ λ /snippets/search-params 1.29 kB 92.6 kB
├ ○ /ssg 287 B 84.8 kB
├ ● /ssg/[id] 1.62 kB 86.2 kB
├ ├ /ssg/1
├ └ /ssg/2
├ ○ /ssr 287 B 84.8 kB
├ λ /ssr/[id] 1.62 kB 86.2 kB
├ ○ /streaming 285 B 84.8 kB
├ ℇ /streaming/edge/product/[id] 856 B 97.4 kB
├ λ /streaming/node/product/[id] 856 B 97.4 kB
├ ○ /styling 287 B 84.8 kB
├ ○ /styling/css-modules 848 B 85.4 kB
├ ○ /styling/global-css 182 B 84.7 kB
├ ○ /styling/styled-components 743 B 96.8 kB
├ ○ /styling/styled-jsx 764 B 89 kB
└ ○ /styling/tailwind 287 B 84.8 kB
+ First Load JS shared by all 84.5 kB
├ chunks/5158-f27d8901244feb42.js 29.2 kB
├ chunks/fd9d1056-7da814dd94c293ae.js 53.3 kB
├ chunks/main-app-6b53cb7e8bd6bde2.js 242 B
└ chunks/webpack-83609d9bd610e6fb.js 1.8 kB
(Static) prerendered as static content
(SSG) prerendered as static HTML (uses getStaticProps)
λ (Dynamic) server-rendered on demand using Node.js
(Edge Runtime) server-rendered on demand using the Edge Runtime

PM2

To run your application in the background, serving many users simultaneously and reliably, we install PM2. PM2 describes itself as a daemon process manager that will help you manage and keep your application online 24/7. Exactly the right thing and good from experience!

To install PM2, use the following command:

sudo npm install pm2@latest -g

Now let's start our app with PM2:

pm2 start "npm run start" --name "nextjs" -- --port 3000

You can change “nextjs” according to the name of your app. This name will be used later with PM2 identifying your app.

Check the status of your app:

pm2 status

You should see anything like this:

┌────┬───────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
├────┼───────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
0 │ nextjs │ default │ N/A │ fork │ 7078 │ 0s │ 7 │ online │ 50% │ 52.8mb │ stella │ disabled │
└────┴───────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘

To start your app with PM2 after a server reboot, we have to add PM2 to the init system with:

pm2 startup

The output will present you with a command to enable the startup script:

[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/usr/local/bin /usr/local/lib/node_modules/pm2/bin/pm2 startup systemd -u stella --hp /home/stella

Run the command:

sudo env PATH=$PATH:/usr/local/bin /usr/local/lib/node_modules/pm2/bin/pm2 startup systemd -u stella --hp /home/stella

Now PM2 will start your app every time after the server is booted. Shaka! 💪

You can monitor the status of your app with the commands:

pm2 status # Current status of your app
pm2 logs # Last 15 lines of the logfile. More lines with the --lines <n> option
pm2 monit # Complete screen with Process List, Logs, Custom Metrics, Metadata

Nginx

For best performance we use Nginx as webserver. Let's install Nginx and Letsencrypt for SSL certificates.

sudo apt install nginx letsencrypt

If you have a firewall enabled with your cloud provider, allow incoming traffic for ports 80 and 443. The same applies if you have UFW running on your server. Then you should configure UFW to allow incoming traffic for Nginx:

sudo ufw allow 'Nginx Full'

Next, edit the default Nginx config file:

sudo vi /etc/nginx/sites-available/default

Change the content to the following lines and replace example.com with your domain:

server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
index index.html index.htm index.nginx-debian.html;
server_name www.example.com; # < replace with your domain
location / {
try_files $uri $uri/ =404;
}
# for letsencrypt
location ~ /.well-known {
allow all;
}
}

Don't forget to set the DNS A entry for this domain pointing to your server.

Restart Nginx:

sudo nginx -t # check config file for syntax errors
sudo systemctl restart nginx

Generate Let's Encrypt certificate using Certbot:

sudo letsencrypt certonly -a webroot --webroot-path=/var/www/html -d example.com -d www.example.com # < replace with your domain

Remember the paths from the certbot result:

Certificate is saved at: /etc/letsencrypt/live/example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/example.com/privkey.pem

Generate a strong Diffie Hellman Key:

sudo openssl dhparam -dsaparam -out /etc/ssl/certs/dhparam.pem 4096

Create a Nginx configuration snippet with parameters for SSL. This will enable strong encryption settings and some advanced features for best security:

sudo vi /etc/nginx/snippets/ssl-params.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
ssl_ecdh_curve secp384r1;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=63072000; includeSubdomains";
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
ssl_dhparam /etc/ssl/certs/dhparam.pem;

If you would like to find out more about these settings, I recommend the article Strong SSL Security on nginx from Remy van Elst.

Now let's edit our Nginx configuration file again:

sudo vi /etc/nginx/sites-available/default
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=nextjs_zone:32m inactive=7d max_size=8G use_temp_path=off;
upstream nextjs_upstream {
server localhost:3000;
}
# redirect http to https
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name example.com www.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2 default_server;
listen [::]:443 ssl http2 default_server;
server_name example.com www.example.com;
server_tokens off;
# Use provided paths fom certbot command
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include snippets/ssl-params.conf;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Serve and cache static files
location /_next/static {
proxy_cache nextjs_zone;
proxy_pass http://nextjs_upstream;
proxy_read_timeout 60;
proxy_connect_timeout 60;
add_header X-Cache $upstream_cache_status;
}
# This is the reverse proxy for our Next.js app
location / {
proxy_pass http://nextjs_upstream;
proxy_read_timeout 60;
proxy_connect_timeout 60;
proxy_redirect off;
# Allow the use of websockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location ~ /.well-known {
allow all;
}
}

Restart Nginx:

sudo nginx -t # check config file for syntax errors
sudo systemctl restart nginx

Check your Next.js App by visiting your domain in any browser.

Done! Now you have a Next.js App running in production with PM2, Nginx Proxy, Cache and SSL. 🥳

Enjoyed this post?

My goal with this blog is to help people to get started with developing wonderful software while doing business and make a living from it.

Subscribe here to get my latest content by email.

I won't send you spam. You can unsubscribe at any time.

© 2024 Headystack. All rights reserved.
👋