Next.js has become not only the leading framework for building React-powered web apps but also a simple way to develop and deploy JavaScript applications with less manual configuration. The developer ergonomics are great. The CLI installer sets up the whole repository with Typescript, Lint, watch mode, configuration files and other basics. There are many deployment options from fully managed to self-hosting. A fully managed deployment solution can get pricey (long running functions can easily ram up the bill) and have technical limitations.

Technical limitations include the lack of fine-grained routing control and no static IP (it is pricey). Fine-grained routing control is useful when routing parts of a domain like /blog/* to an external blog engine like Hugo. Deploying on VPS is a viable and cheap alternative. I use to run expensive API functions while the main web app is deployed on a fast and fully managed solution with global CDN (which still runs on free tier).

VPS to the rescue

Deploying on VPS is, even if you aren’t particular well-versed in Linux server management, not too complicated. There exists multiple tutorials and build scripts that will setup SSL encryption (Let’s Encrypt), NGinx and Node.

In this example I use the node process manager, PM2. It handles process restarts, CPU cluster mode, logging and environmental variables. Add a ecosystem.config.js to the root of the Next.js project:

module.exports = {
  apps: [
    {
      name: 'my-app',
      exec_mode: 'cluster',
      instances: 'max',
      script: 'node_modules/next/dist/bin/next',
      watch: false,
    },
  ],
};

And then a command to (re)start the application to the package.json:

"scripts": {
  "restart": "pm2 startOrRestart ecosystem.config.js --env production --update-env"
}

The --update-env ensures that environmental variables passed from Github during deployment are updated.

Continuous deployment using Github Actions

The last step automatizing deploying. This is done by adding a deployment script for Github Actions:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Use Node.js 18
        uses: actions/setup-node@v3
        with:
          node-version: 18.x

      - name: Run install
        run: yarn install --frozen-lockfile

      - name: Run build
        run: yarn build

      - name: Deploy using ssh
        uses: appleboy/ssh-action@master
        env:
          YOUR_SECRET: ${{ secrets.YOUR_SECRET }}
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.PRIVATE_KEY }}
          port: 22
          script: |
            cd /home/user/web-app
            git pull origin main
            rm -rf .next
            yarn install --frozen-lockfile
            yarn build
            YOUR_SECRET=${{ env.YOUR_SECRET }} yarn restart

The deployment script runs whenever the main branch is updated. It builds the application to detect builds errors before trying to deploy. It adds the secret environmental variable YOUR_SECRET managed by Github on the repository settings page.

An alternative deployment method would be to sync the build files from Github Actions using SCP to avoid re-building it on the deployed VPS. This would also make it more efficient and fast. This can easily be used to deploy to multiple servers depending on setup.

References