In the vibrant world of web development, Next.js has emerged as a standout framework for crafting sophisticated web applications. While the convenience and simplicity of serverless platforms like Vercel are appealing, they don't always align with every project's needs. This article is dedicated to exploring the advantages of using a Virtual Private Server (VPS) and guiding you through the process of deploying a Next.js application on it.

Pros and Cons: Serverless Computing vs. Virtual Private Servers (VPS)

When considering serverless computing, its benefits include scalable architecture, cost-effectiveness for fluctuating workloads, reduced operational overhead, quicker deployment times, and built-in high availability. However, challenges such as cold start issues, limited environmental control, and potential vendor lock-in are notable.

In contrast, VPS offers complete control over the server environment, predictable costs, consistent performance, and enhanced security control. The downsides include the need for regular maintenance, complexities in scaling, potentially higher costs for small or variable workloads, the requirement for technical expertise, and the risk of resource underutilization.

Despite the perceived complexity of using a VPS, this guide aims to simplify the process. From choosing the right VPS provider to configuring your server and setting up a CI/CD pipeline, I'll provide comprehensive, step-by-step guidance. By the end, you'll not only have a functioning Next.js application on a VPS but also the confidence to manage it with ease.

Selecting the Right VPS Provider

Choosing an appropriate VPS provider is a pivotal step. Considerations include balancing scalability with cost-effectiveness. Recommended providers are:

  1. Amazon Web Services (AWS): Ideal for scalable needs, AWS offers a free-tier for starters, easy upgrade paths, and robust backup solutions.

  2. Linode: Known for its simplicity and customer support, Linode provides straightforward pricing and reliable service, suitable for various project sizes.

  3. Hostinger: An affordable option, Hostinger is user-friendly and well-suited for smaller to medium-sized projects.

Each provider caters to different requirements. Your selection should align with your project's traffic expectations, budget, and technical demands. For personal projects, I prefer Hostinger, while AWS serves most of my larger clients. If you're inclined towards Hostinger, using my referral link at the article's end supports me!


Preparing Your VPS: Essential Software Installation

Once your VPS is up and running, the next step is installing key applications:

  1. NGINX: A fast, configurable HTTP server, NGINX will manage all requests to your Next.js app, including HTTPS connections.

  2. Certbot: This tool automates obtaining free SSL certificates from LetsEncrypt.org, crucial for secure communication.

  3. GitHub CLI (gh): Essential for CI/CD pipeline integration, it enables efficient interaction with GitHub repositories.

  4. Node.js: We'll need it for building and running our Next.js app.

First, make sure the system is up to date:

sudo apt update; apt -y dist-upgrade

Restart, if needed, then install our apps:

sudo apt -y install nginx gh
sudo snap install --classic certbot

We'll install Node.js from Node Source:

sudo apt-get install -y ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
sudo apt-get update
sudo apt-get install nodejs -y

Lastly, we'll install the latest NPM and PNPM:

npm i -G npm pnpm

Setting Up the Build Environment

We'll need a place to store our Git repository, so we'll create a system user called web and we'll keep our repo in the home directory:

adduser web

You can fill out the finger information if you want, or just leave it all blank. Be sure to choose a secure password. You'll never need to remember it, so you can randomly generate a strong password. Now you can switch to that user by running sudo su web.

Next, let's pull our Next.js app from Github for the first time:

cd
gh auth login

Github will ask you if you want to login to GitHub.com or an enterprise server, choose Github.com. Next, choose SSH as your preferred protocol and a SSH key will be automatically generated. Next, choose "login with web browser." There's no X server on a VPS, so you'll need to manually go to https://github.com/login/device and enter the code displayed in the terminal. Once you enter the code into the web browser, you should be logged in.

Now we can pull our repo like this:

gh repo clone myusername/myapp

You should now have a "myapp" directory in the home directory of the web user. Next, we're going to rename it to "myapp-a" and then copy the directory to "myapp-b":

mv myapp myapp-a
cp -r myapp-a myapp-b

The reason we're doing this is so we can have a directory for our live running server and one for building when we run our CI/CD script. That way there will be no interruption during the build. So what we'll do is create a symlink that points to myapp-a to start:

ln -s ./myapp-a myapp-live

Creating our CI/CD Scripts

Let's write a few scripts that will allow us to build our app with a single command. So go ahead and fire up pico:

pico /usr/local/bin/myapp-build

Now enter the following code, change to suite your needs:

#!/bin/bash
# add the following line to sudoers:
# web ALL=(root) NOPASSWD: /usr/sbin/service myapp restart
# Define the paths
symlink_path="/home/web/myapp-live"
path_a="/home/web/myapp-a"
path_b="/home/web/myapp-b"
service_name="myapp"
# Determine the current target of the symlink
current_target=$(readlink -f "$symlink_path")
# Decide which path is not in use
if [ "$current_target" = "$path_a" ]; then
    unused_path="$path_b"
    switch_to="b"
else
    unused_path="$path_a"
    switch_to="a"
fi
# Change to the unused path
echo "Changing directory"
cd "$unused_path" || { echo "Failed to change directory to $unused_path"; exit 1; }
# Pull from GitHub
echo "Pulling from GitHub..."
git pull
# Run pnpm install
echo "Installing dependencies..."
pnpm install || { echo "Dependency installation failed"; exit 1; }
# Run npm run build
echo "Running Next.js build..."
npm run build
# Check if the build succeeded
if [ $? -eq 0 ]; then
    echo "Build succeeded. Proceeding with the switch and Nginx reload."
    # Switch to the newly built version
    vt-switch "$switch_to" || { echo "Failed to switch to $switch_to"; exit 1; }
    echo "Restarting vtapp service..."
    sudo service "$service_name" restart
    echo "Successfully switched to $unused_path"
else
    echo "Build failed. Aborting deployment."
    exit 1
fi

Now let's create another script /usr/local/bin/myapp-switch:

#!/bin/bash

# Check if the argument is either "a" or "b"
if [ "$1" = "a" ] || [ "$1" = "b" ]; then
    # Assign the link path
    linkPath=/home/web/myapp-live
    # Remove the existing symlink if it exists
    rm -f "$linkPath"
    # Create the symbolic link
    ln -s "/home/web/myapp-$1" "$linkPath"
else
    # Print an error message if the argument is not "a" or "b"
    echo "Error: Argument must be 'a' or 'b'."
    exit 1
fi

Now we'll create another script that will allow us to quickly rollback to the previous deployment if something goes wrong with the current one. We'll put it at /usr/local/bin/myapp-rollback:

#!/bin/bash

# Define the paths
symlink_path="/home/web/myapp-live"
path_a="/home/web/myapp-a"
path_b="/home/web/myapp-b"
service_name="myapp"
# Function to switch the symlink
switch_symlink() {
    target=$1
    echo "Switching to $target"
    rm -f "$symlink_path"
    ln -s "$target" "$symlink_path"
    echo "Switched symlink to $target"
}
# Determine the current target of the symlink
current_target=$(readlink -f "$symlink_path")
# Decide which path to switch to
if [ "$current_target" = "$path_a" ]; then
    switch_symlink "$path_b"
    elif [ "$current_target" = "$path_b" ]; then
    switch_symlink "$path_a"
else
    echo "Current symlink target is not recognized."
    exit 1
fi
sudo service "$service_name" restart
echo "Rollback completed."

Lastly, we need to make those scripts executable, so just run:

chmod +x /usr/local/bin/*

Setting Up The Service

Ok, we're getting close! We're going to need to run our Next.js app as a systemd service. This will take care of starting up our app at system boot, restarting on failure and logging. To do that, we'll need to create a config file at /etc/systemd/system/myapp:

[Unit]
Description=An Awesome Next.js App!
After=network.target
[Service]
Type=simple
User=web
Group=web
WorkingDirectory=/home/web/myapp-live
ExecStart=/bin/sh -c '/usr/bin/npm start >> /var/log/myapp/myapp.log 2>&1'
Restart=on-failure
StandardOutput=null
StandardError=null
[Install]
WantedBy=multi-user.target

What this does is it runs npm start as the web user and redirects stderr and stdout to /var/log/myapp/myapp.log. It also directs systemd to restart on failure. It also makes sure that the network is up and reachable before starting (particularly important if you have remote API calls at build time).

Now we need to create the log directory and make sure it's writable by the web user:

mkdir /var/log/myapp
chown web.web /var/log/myapp

TIP: you can watch the log live once the server is up by typing: tail -f /var/log/myapp/myapp.log.

Now we need to reload systemd and enable the service:

systemctl daemon-reload
systemctl enable myapp

Now we don't want the log file to just continue to grow and grow forever, so we'll need to leverage logrotate to manage archiving our log file. To do that, we'll create a config file at: /etc/logrotate.d/myapp:

/var/log/myapp/myapp.log {
    daily
    missingok
    rotate 7
    compress
    delaycompress
    notifempty
    create 640 web web
    sharedscripts
    postrotate
        systemctl restart myapp
    endscript
}

What this does is every day, logrotate will compress the current log file to gz format and then restart the service so a new log file is created. It will keep logs for each day of the week. It also makes the log file readable only by the web user.


Setting Up NGINX

We're going to use NGINX to create a reverse proxy to our Next.js app. NGINX will handle the multitude of connections coming and and also handle our SSL. To get started navigate to /etc/nginx/sites-enabled and delete the default site config file there. Then backout and go to ../sites-available. We'll create a config file called myapp.com:

server {
    server_name myapp.com;
    error_page 502 =200 /fallback.html;
    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_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_intercept_errors on;
    }
    location = /fallback.html {
        root /home/web/myapp-live/static/;
        internal;
    }
}

So this will forward any incoming connection to http://localhost:3000. If Next.js is down, it will serve fallback.html. You'll need to create your own static HTML file for this. You can do something like "We're currently in maintenance mode," or something like that.

To enable this config, just run ln -s /etc/nginx/sites-available/myapp.com /etc/nginx/sites-enabled/. Then type systemctl reload nginx.


Setting Up SSL via Certbot

Before we run certbot, you'll need to make sure that both ports 80 and 443 are open on your VPS. You'll need to keep port 80 open for certbot to run automatically to renew your cert. Certbot will automatically update your NGINX config file for the SSL config and create a rewrite rule to redirect non-SSL connections to SSL. To run certbot, all you need to do it run:

certbot nginx

You'll need to agree to the terms and enter an admin email address to get notifications about certs that are expiring soon. Next, you should see your myapp.com domain listed. Select your domain and hit enter.

TIP: If you have multiple domains, you can just hit enter and it will create a combined certificate for all subdomains.

Hopefully you see the "congratulations" message which means your certificate is installed and NGINX is already reloaded and ready to go! If you get an error, you most likely have either a DNS or firewall problem. Make sure your DNS points to the public ip address of your VPS and you have ports 80 and 443 open on your firewall.

The Last Step: Building Our App

We need to do one last thing before we build our app. We need to allow the web user to restart the service as root once the build is complete. To do this run the command visudo and enter the following line:

web ALL=(root) NOPASSWD: /usr/sbin/service myapp restart

Now switch back to the web user and navigate to your home directory (cd). Then run the build command myapp-build. PNPM will install the dependencies and the app should start building. If the build is completed successfully, the service should start. Check your domain name in the browser. Hopefully you see your site up and running!

If I missed anything in this guide, if you have anything to add, or just want to express your thanks, please feel free to leave a comment! 😊

Resources

Also, if you decide to go with Hostinger, please follow this link so I can get a little credit. Thanks!


Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.