Self-Hosting Next.js on a VPS - End-to-End

1 week ago
|
0 views

TL;DR: How to self-host Next.js (using the page router) for 5k users per day

  • OS: Ubuntu (latest LTS version)
  • CDN: Cloudflare
  • Deploy: Docker
  • Reverse proxy: NGINX
  • Database: PostgreSQL
  • Monitoring: New Relic, Sentry
  • Logging: Telegram
  • Backup: Cloudflare R2
  • Secure: UFW, Fail2Ban, VPS Firewall

Why did I write this blog?

I'm a big fan of Next.js, and I'm also a big fan of self-hosting. I learned so much when I self-hosted my app on a VPS instead of just hitting the deploy button on Vercel.

When do you need to self-host your Next.js app?

  • You want to have full control over your app.
  • Your app consumes a huge amount of resources and bandwidth.
  • You want to learn more about DevOps.
  • You want to host a database on the same VPS.

Other blogs may guide you on how to set up your Next.js app on a VPS, but I want to walk you through setting up your Next.js app end-to-end—from deploying it and auto-deploying it to securing and scaling it.

We will make it work > optimize it > secure it > make it fast > make it stable.

Let's go 🏃‍➡️

1. Make it work

Pick a provider

From personal experience, I recommend these providers as they offer the best price/performance ratio:

  • DigitalOcean: Great UI, super stable, free DDoS protection.
  • Linode: Great price, great performance, free DDoS protection.
  • Vultr: Great price, great performance, DDoS protection for $10/month.

I would recommend DigitalOcean because it's the easiest to use, stable, and its free DDoS protection is a big plus. Additionally, it provides many other services like managed databases, app platforms, functions, etc. Everything comes with a great UI and is very user friendly.

For one Next.js app plus one database, a $6 plan with 1 vCPU, 1GB RAM, and 25GB SSD storage should be enough.

You can use my referral link to get $200 credit (60 days) for free: DigitalOcean

DigitalOcean

Set up your VPS

Now, let's set up your VPS. This is my recommended setup:

  • Docker: For easy deployment and management.
  • NGINX: Reverse proxy.
  • PostgreSQL: Database.
  • UFW, Fail2Ban: For security.
  • VPS Firewall: Use your provider's firewall and, if using Cloudflare, allow only Cloudflare IPs.

You may wonder why we even need Docker for a single, small Next.js app. Isn't that overkill?
But hear me out—even though you might have a $5 VPS, it won't appreciate you when building the black hole of dependencies in your app. The build step consumes a huge amount of resources, and many times my build step failed due to JavaScript heap out of memory.

Check out this script deploy.sh to automate the setup process of your server.

Run your Next.js app

For reference, I think the best template is Leerob's template.

Deploy

For auto-deployment, it's better to have GitHub Actions build your app, dockerize it, push the image to a private registry, and then on your VPS, you simply pull the image and run it. Next.js's build step is a nightmare for a $5 VPS.

This is my GitHub Actions workflow for auto-deploying each time I push to the main branch. It builds the app, dockerizes it, pushes the image to GitHub Package Registry, then SSH's into your VPS to pull the image and run it.

Deploy

You will need to set up the following secrets in your GitHub repository:

  • GITHUB_TOKEN: (Automatically generated by GitHub)
  • SSH_HOST: Your VPS IP address.
  • SSH_USERNAME: Your VPS username.
  • SSH_PORT: Your VPS port.
  • SSH_PRIVATE_KEY: Your VPS private key.
name: Build and Deploy to VPS
 
on:
  push:
    branches:
      - main
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
 
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
 
      - name: SSH into VPS then pull image and run
        id: deploy
        uses: appleboy/[email protected]
        env:
          IMAGE_TAG: ${{ steps.meta.outputs.tags }}
          GITHUB_USERNAME: ${{ github.actor }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          envs: GITHUB_USERNAME, GITHUB_TOKEN, IMAGE_TAG
          script: |
            echo $GITHUB_TOKEN | docker login ghcr.io -u $GITHUB_USERNAME --password-stdin
            docker pull ghcr.io/$GITHUB_USERNAME/$IMAGE_NAME:$IMAGE_TAG
            docker stop $(docker ps -a -q)
            docker system prune -f
            docker run --name next-app -dit ghcr.io/$GITHUB_USERNAME/$IMAGE_NAME:$IMAGE_TAG

2. Make it optimize

Avoid Unnecessary Requests Hitting Your Server

To reduce the load on your VPS, you need to make some changes to avoid unnecessary requests.

Next.js (using the page router) has several kinds of requests:

  • /{page_name}: normal page requests
  • /{static_files}: static files in the public folder
  • /api/*: API route requests
  • /_next/image: optimized images
  • /_next/static: static files
  • /_next/data: data fetching

If you proxy your server through Cloudflare (and you should), you can use Cloudflare's CDN to cache your static files and images.

Cloudflare CDN already caches all of your static files (JS, CSS, fonts) by default as their filenames are hashed when the content changes. However, for files in the public folder and for /_next/image, you need to set custom cache rules.

  • For files in the public folder, you can place them in a specific subfolder (such as public/__images) and set a cache rule to cache them for one year. Note that if you want to update them, you need to use a different name to avoid cache hits that would show outdated content.
  • For /_next/image, you can set a cache rule to cache them for one year—either by ignoring the cache-control header or by setting a fixed TTL.

Avoid Unnecessary Server-Side Rendering

Believe me, SSR consumes a lot of resources, and it is not advisable to use it for every page of your app.

Mastering ISR can be a little tricky, but it's worth it.

3. Make it secure

Secure your server

UFW

DigitalOcean has a great tutorial on how to set up UFW on Ubuntu.

Make sure to allow the SSH port (22) before enabling UFW, or you will not be able to SSH into your server.

Note: When you use Docker, it uses iptables directly and UFW might not work as expected. You may need to use iptables to manage port access.

Fail2Ban

Your server is exposed to the internet, and someone could attempt to brute-force SSH to guess the password. Use Fail2Ban to block an IP address after a certain number of failed attempts.

Again, DigitalOcean has a great tutorial on how to set up Fail2Ban on a Linux server.

VPS Firewall

Every VPS provider offers a firewall to control incoming and outgoing traffic.
If you use Cloudflare, set up the firewall to allow only traffic from Cloudflare's IP addresses (Cloudflare IPs). This prevents direct traffic to your server's IP.

4. Make it fast

Caching

[... additional content about caching ...]

5. Make it stable

Monitoring

New Relic

New Relic has a generous free tier and is very simple to set up. You can use it to monitor your server, your app, your database, etc.
I use it to monitor my server and containers.

Sentry

Sentry has become the de facto standard for error monitoring. It is very easy to set up and free for small usages. However, honestly, the free tier isn't enough for me, so I log errors to Telegram.

Logging

I log everything in my app using a Telegram bot. It's simple, and many SDKs support it.
Note that Telegram has a rate limit, so make sure to configure it correctly to avoid hitting that limit. (I use a queue with BullMQ to send messages.)

Backup

As you run your database on the server, it is essential to back it up.

You can use any S3-compatible storage for backups. I use Cloudflare R2 for mine.

I set up a JavaScript script to run as a cron job to back up my database every day at 12:00 AM UTC. PM2 makes this easy.

// backup.js
const fs = require("fs");
const path = require("path");
const dayjs = require("dayjs");
const mysqldump = require("mysqldump");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
 
const s3 = new S3Client({ ... });
const dbConnection = '...';
 
const FOLDER_PATH = "/path_backup";
const fileName = `${dayjs().format("DD_MM_YYYY_HH_mm_ss")}_backup.sql`;
const backupPath = path.join(FOLDER_PATH, fileName);
 
if (!fs.existsSync(FOLDER_PATH)) {
  fs.mkdirSync(FOLDER_PATH);
}
 
console.log(">>> Start backup database ...");
 
mysqldump({
  connection: dbConnection,
  dumpToFile: backupPath,
})
  .then(async () => {
    const fileSize = fs.statSync(backupPath).size;
 
    console.log(`>>> Backup file ${backupPath} created successfully`);
    console.log(`>>> Backup file size ${fileSize}`);
 
    // upload the file to S3
    const fileBuffer = fs.readFileSync(backupPath);
 
    const command = new PutObjectCommand({
      Bucket: 'your_buck_name',
      Key: fileName,
      Body: fileBuffer,
    });
    await s3.send(command);
 
    console.log(`✅ Backup file ${fileName} uploaded to S3 successfully`);
 
    fs.unlinkSync(backupPath);
  })
  .catch((error) => {
    console.log(`❌ Error creating backup file: ${error}`);
  });
pm2 start backup.js --cron "0 0 * * *"

Happy coding! 🚀