Deploy Next.js App With Nginx, Let's Encrypt and PM2

Time to deploy this blog onto the World Wide Web šŸŒŽ


As long as you have a server with Nginx setup, you're all set. If not, follow this amazing NGNIX setup guide.

Now that you're all set, let's get started.

Get The App Onto Your Server

My application is already in a repo so I'm just going to ssh into my server and git clone it. Alternatively, you can also upload the application to the server using scp or a ftp client. Or wget it if you have it uploaded somewhere.

I've cloned my application in /var/app, you can put it where ever you like. Other common choices are your user's home directory, /opt, var/www, and /usr/local/.

Build Next.js App and Serve It

Next.js supports static builds. The current version of the blog does support static build but I know I'll be including features in the future that won't work so well with Next.js static builds. So I'm going to build and serve it the old fashion way, with pm2.

First, get on your server and install pm2 if you haven't already. I'm going to do this globally.

yarn global add pm2

Now make sure you are in the application's root directory. Install your dependencies if you haven't yet:

yarn install

Build our application. For my Next.js app, it's:

$ yarn build
yarn run v1.22.10
$ next build
info  - Loaded env from /var/app/blog/.env.production
info  - Using webpack 4. Reason: future.webpack5 option not enabled
info  - Checking validity of types
info  - Creating an optimized production build
info  - Compiled successfully
info  - Collecting page data
info  - Generating static pages (10/10)
info  - Finalizing page optimization

Page                                                                             Size     First Load JS
ā”Œ ā— /                                                                            3 kB           83.9 kB
ā”œ   /_app                                                                        0 B            80.9 kB
ā”œ ā—‹ /404                                                                         3.03 kB          84 kB
ā”œ Ī» /api/posts                                                                   0 B            80.9 kB
ā”” ā— /posts/[slug]                                                                36.8 kB         118 kB
    ā”œ /en/posts/compile-markdown-to-html-using-node-js-with-syntax-highlighting
    ā”œ /en/posts/intro
    ā”œ /en/posts/add-line-numbers-to-markdown-code-blocks
    ā”” [+2 more paths]
+ First Load JS shared by all                                                    80.9 kB
  ā”œ chunks/6335da8543c52d3af527c387f688061357787349.5e69d6.js                    16.3 kB
  ā”œ chunks/commons.50912b.js                                                     14.1 kB
  ā”œ chunks/framework.1cddd9.js                                                   41.8 kB
  ā”œ chunks/main.64dec6.js                                                        7.26 kB
  ā”œ chunks/pages/_app.f1b695.js                                                  689 B
  ā”œ chunks/webpack.50bee0.js                                                     751 B
  ā”” css/8455dd40f9ed50b7cf37.css                                                 1.53 kB

Ī»  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
ā—‹  (Static)  automatically rendered as static HTML (uses no initial props)
ā—  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)

Done in 26.86s.

Give it some time, depending on the number of pages you have to build it might take longer.

Now let's serve our application.

pm2 start npm --name blog -- start

blog is the unique name I gave this pm2 process. We can easily reference this later.

To see if the process is running fine we can check it by viewing it.

$ pm2 ls
ā”‚ id  ā”‚ name           ā”‚ namespace   ā”‚ version ā”‚ mode    ā”‚ pid      ā”‚ uptime ā”‚ ā†ŗ    ā”‚ status    ā”‚ cpu      ā”‚ mem      ā”‚ user     ā”‚ watching ā”‚
ā”‚ 0   ā”‚ blog           ā”‚ default     ā”‚ 0.37.2  ā”‚ fork    ā”‚ 272800   ā”‚ 3s     ā”‚ 1    ā”‚ online    ā”‚ 0%       ā”‚ 53.7mb   ā”‚ root     ā”‚ disabled ā”‚

Look for the row with your process's name and check the status column, it should say online. If the status isn't online you can view the logs via pm2 log to see your application's log.

Create Nginx Server Block

Alright, so we have our application up and running but the outside world cannot access it. We'll need to create an Nginx server block to expose our domain/port and proxy the request to our app.

The Nginx config files on my server are located in /etc/nginx/sites-available. I'm going to create a new one for my application.

sudo vi /etc/nginx/sites-available/

Replace with your domain, or a filename of your choice.

In this file, we'll just have a very bare minimum config. All we want is to proxy pass incoming request to our server to our application

server {
  listen 80;
  listen [::]:80;


  location / {
    proxy_pass http://localhost:3000;
    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;

Now you'd want to replace server_name with your domain. And if your application is running on another port, you'd also want to replace that 3000 with your port in the proxy_pass value.

Next, let's link our config file to the sites-enabled directory:

sudo ln -s /etc/nginx/sites-available/ /etc/nginx/sites-enabled/

Remember to always test your Nginx configs, especially before you restart it:

sudo nginx -t

We should see a success message. If there is an error you'll have to open up your Nginx error logs and see what's up.

Add HTTPS Support

What site doesn't have a padlock next to their URL in the address bar? Ours will be no exception. Just follow the SSL steps in this other amazing guide for setting up Let's Encrypt on your server.

If the challenge fails for you make sure your domain is pointing to your server. You might have to wait a bit if you just configured your DNS. You can check your DNS propagation here.

After you follow the guide successfully, Certbot will sprinkle some extra code into your Nginx config file.

I've also added some extra redirects in here to make users coming in from to just

Here's my final config:

server {

  if ($host = {
    return 301$request_uri;

  location / {
    proxy_pass http://localhost:3000;
    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;

  listen [::]:443 ssl; # managed by Certbot
  listen 443 ssl; # managed by Certbot
  ssl_certificate /etc/letsencrypt/live/; # managed by Certbot
  ssl_certificate_key /etc/letsencrypt/live/; # managed by Certbot
  include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
  ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

server {
  if ($host = {
    return 301$request_uri;
  } # managed by Certbot

  if ($host = {
    return 301 https://$host$request_uri;
  } # managed by Certbot

  listen 80;
  listen [::]:80;

  return 404; # managed by Certbot

Now let's test it one more time before we restart Nginx:

sudo nginx -t

And if everything is looking good. Let's restart Nginx:

sudo systemctl reload nginx

Now if we've done this properly, we can go to our domain and we should see our application up and running.

Serving Static Content

If you inspect the network traffic you might notice some 404 errors trying to go to the URL path/_next/static. We need to add this to let Nginx know where the static folder is and how to get there.

This can easily be done by adding another location block inside the config file.

location /_next/static/ {
  alias /var/app/blog/.next/static/;

Now we can't be missing the crucial step to make our site more performant. We can enable gzip and update the cache control to send down these static files faster and persist them so the browser doesn't need to keep requesting it.

Update the location block like so:

location /_next/static/ {
  gzip on;
  gzip_comp_level 5;
  gzip_proxied any;
  gzip_vary on;
  gzip_min_length 1024;
  gzip_types application/javascript;
  expires 30d;
  alias /var/app/blog/.next/static/;

I only added javascript to the gzip_types because when I was inspecting the network request I only saw javascript files. If you happen to have additional static file types, you can add them to gzip_types so they also get the gzip compression benefits


Not working? Check the following:

  1. Is your domain pointing to your server's IP address?
  2. Has your domain propagated through the interwebs? Check here?
  3. Your Nginx config has the correct fields for server_name and proxy_pass. Correct port?
  4. pm2 process is up and running?


If you are seeing this blog post I guess I've done the above and deploy this thing šŸŽ‰