DEV Community

Cover image for How to deploy a Next.js app to a VPS on Hetzner using Docker and GitHub Actions
Anton Prudkohliad
Anton Prudkohliad

Posted on • Originally published at prudkohliad.com

How to deploy a Next.js app to a VPS on Hetzner using Docker and GitHub Actions

Deploying a Next.js app to a Virtual Private Server (VPS) on Hetzner can be a robust and cost-effective solution for hosting your application. This tutorial will guide you through the entire process, from writing a Dockerfile, to automating deployments using GitHub Actions. We'll also cover how to configure DNS and SSL certificates using Cloudflare. By the end, you'll have a fully automated deployment pipeline for your Next.js app running smoothly on a Hetzner VPS.

Before you begin

Additional files to ignore

Some files are just not meant to be stored in a version control system. Add the following to .gitignore to not bloat your repository:

# next.js
/.next/
/out/

# misc
.DS_Store
*.pem

# debug
yarn-debug.log*
yarn-error.log*

# local env files
.env*
!.env
Enter fullscreen mode Exit fullscreen mode

Dockerfile

I find the original Next.js tutorial a little bit misguiding, because it tells you to copy the .env files into the Docker image and run the next build command during the Docker image build. This means that all your secrets (e.g. from the .env.production file, where you might have something like your database URL) might end up in the final image. Which is an equivalent of storing secrets in your GitHub repository and might pose a security risk.

Therefore, this image will not run the next build command and instead focus on installing dependencies. We will build the application later, in the production environment in a bootstrap container, a.k.a. Init Container. Please find the full Dockerfile below:

# The base image
FROM node:20.16.0-bookworm-slim AS base



# The "dependencies" stage
# It's good to install dependencies in a separate stage to be explicit about
# the files that make it into production stage to avoid image bloat
FROM base AS deps

# Enable Corepack so that Yarn can be installed
RUN corepack enable

# The application directory
WORKDIR /app

# Copy fiels for package management
COPY package.json yarn.lock .yarnrc.yml ./

# Install packages
RUN yarn install --immutable --inline-builds



# The final image
FROM base AS production

# Enable Corepack so that Yarn can be installed
RUN corepack enable

# Create a group and a non-root user to run the app
RUN groupadd --gid 1001 "nodejs"
RUN useradd --uid 1001 --create-home --shell /bin/bash --groups "nodejs" "nextjs"

# The application directory
WORKDIR /app

# Make sure that the .next directory exists
RUN mkdir -p /app/.next && chown -R nextjs:nodejs /app

# Copy packages from the dependencies stage
COPY --from=deps --chown=nextjs:nodejs /app/.yarn /app/.yarn

# Copy the rest of the application files
COPY --chown=nextjs:nodejs . .

# Enable production mode
ENV NODE_ENV=production

# Disable Next.js telemetry
ENV NEXT_TELEMETRY_DISABLED=1

# Configure application port
ENV PORT=3000

# Let image users know what port the app is going to listen on
EXPOSE 3000

# Change the user
USER nextjs:nodejs

# Make sure dependencies are picked up correctly
RUN yarn install --immutable --inline-builds
Enter fullscreen mode Exit fullscreen mode

In order for Yarn to be able to copy it’s cache properly, let’s tell it to store the cache files in the local .yarn directory. Make sure that the .yarnrc.yml contains the following:

enableGlobalCache: false
enableTelemetry: false
Enter fullscreen mode Exit fullscreen mode

To avoid the image bloat let’s also create a .dockerignore file. Copy everything from the .gitignore file and add the following:

# Dev tools
.git
.github
.editorconfig
.gitattributes
.node-version

# Docker files
.dockerignore*
Dockerfile*
docker-compose*.yml

# Readme
README.md
Enter fullscreen mode Exit fullscreen mode

Test the Docker Image

Let’s test our Docker image. We will create two containers:

  • The “build” container app_build that will run the yarn build command, making sure that the Next.js application is built and stored in the .next directory The “runner” container app that will wait for the “build” container to complete successfuly, utilizing Docker healthcheck mechanism and then start the server using yarn start Both containers will be connected via a shared volume called app_build that will be mounted to the location of the .next directory.

Create a docker-compose.yml file:

services:
  # The main application service
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    volumes:
      - "app_build:/app/.next"
    ports:
      - "3000:3000"
    command: ["yarn", "start"]
    depends_on:
      app_build:
        condition: service_completed_successfully
  # The one-off container that builds the application
  app_build:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    volumes:
      - "app_build:/app/.next"
    command: ["yarn", "build"]
volumes:
  # The volume that is going to store the .next directory where the built
  # application is located
  app_build: {}
Enter fullscreen mode Exit fullscreen mode

Now let’s start the app:

docker compose up --build app
Enter fullscreen mode Exit fullscreen mode

Docker will build the image, run the app_build container first, wait for it to finish and then start the app container:

[+] Building 1.3s (26/26) FINISHED                                                                                                                                                               docker:desktop-linux
 => [app_build internal] load build definition from Dockerfile                                                                                                                                                   0.0s
 => => transferring dockerfile: 1.49kB                                                                                                                                                                           0.0s
 => [app_build internal] load .dockerignore                                                                                                                                                                      0.0s
 => => transferring context: 711B                                                                                                                                                                                0.0s
 => [app internal] load metadata for docker.io/library/node:20.16.0-bookworm-slim                                                                                                                                1.1s
 => [app base 1/1] FROM docker.io/library/node:20.16.0-bookworm-slim@sha256:a22f79e64de59efd3533828aecc9817bfdc1cd37dde598aa27d6065e7b1f0abc                                                                     0.0s
 => [app_build internal] load build context                                                                                                                                                                      0.0s
 => => transferring context: 236B                                                                                                                                                                                0.0s
 => CACHED [app deps 1/4] RUN corepack enable                                                                                                                                                                    0.0s
 => CACHED [app production 2/8] RUN groupadd --gid 1001 "nodejs"                                                                                                                                                 0.0s
 => CACHED [app production 3/8] RUN useradd --uid 1001 --create-home --shell /bin/bash --groups "nodejs" "nextjs"                                                                                                0.0s
 => CACHED [app production 4/8] WORKDIR /app                                                                                                                                                                     0.0s
 => CACHED [app production 5/8] RUN mkdir -p /app/.next && chown -R nextjs:nodejs /app                                                                                                                           0.0s
 => CACHED [app deps 2/4] WORKDIR /app                                                                                                                                                                           0.0s
 => CACHED [app_build deps 3/4] COPY package.json yarn.lock .yarnrc.yml ./                                                                                                                                       0.0s
 => CACHED [app_build deps 4/4] RUN yarn install --immutable --inline-builds                                                                                                                                     0.0s
 => CACHED [app_build production 6/8] COPY --from=deps --chown=nextjs:nodejs /app/.yarn /app/.yarn                                                                                                               0.0s
 => CACHED [app_build production 7/8] COPY --chown=nextjs:nodejs . .                                                                                                                                             0.0s
 => CACHED [app_build production 8/8] RUN yarn install --immutable --inline-builds                                                                                                                               0.0s
 => [app_build] exporting to image                                                                                                                                                                               0.0s
 => => exporting layers                                                                                                                                                                                          0.0s
 => => writing image sha256:1a4521137568e396fbdffe46689bbfaf5a118fba9f860f051c7526f9ed96b353                                                                                                                     0.0s
 => => naming to docker.io/library/next-self-hosted-app_build                                                                                                                                                    0.0s
 => [app internal] load .dockerignore                                                                                                                                                                            0.0s
 => => transferring context: 711B                                                                                                                                                                                0.0s
 => [app internal] load build definition from Dockerfile                                                                                                                                                         0.0s
 => => transferring dockerfile: 1.49kB                                                                                                                                                                           0.0s
 => [app internal] load build context                                                                                                                                                                            0.0s
 => => transferring context: 236B                                                                                                                                                                                0.0s
 => CACHED [app deps 3/4] COPY package.json yarn.lock .yarnrc.yml ./                                                                                                                                             0.0s
 => CACHED [app deps 4/4] RUN yarn install --immutable --inline-builds                                                                                                                                           0.0s
 => CACHED [app production 6/8] COPY --from=deps --chown=nextjs:nodejs /app/.yarn /app/.yarn                                                                                                                     0.0s
 => CACHED [app production 7/8] COPY --chown=nextjs:nodejs . .                                                                                                                                                   0.0s
 => CACHED [app production 8/8] RUN yarn install --immutable --inline-builds                                                                                                                                     0.0s
 => [app] exporting to image                                                                                                                                                                                     0.0s
 => => exporting layers                                                                                                                                                                                          0.0s
 => => writing image sha256:8bdd829336f1ddb87571955f221e76da1fab9cf1e98ad921fc2625f82eb65d88                                                                                                                     0.0s
 => => naming to docker.io/library/next-self-hosted-app                                                                                                                                                          0.0s
[+] Running 1/0
 ✔ Container next-self-hosted-app_build-1  Created                                                                                                                                                               0.0s
Attaching to next-self-hosted-app-1, next-self-hosted-app_build-1
next-self-hosted-app_build-1  |   ▲ Next.js 14.2.5
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  |    Creating an optimized production build ...
next-self-hosted-app_build-1  |  ✓ Compiled successfully
next-self-hosted-app_build-1  |    Linting and checking validity of types ...
next-self-hosted-app_build-1  |    Collecting page data ...
next-self-hosted-app_build-1  |    Generating static pages (0/4) ...
next-self-hosted-app_build-1  |    Generating static pages (1/4)
next-self-hosted-app_build-1  |    Generating static pages (2/4)
next-self-hosted-app_build-1  |    Generating static pages (3/4)
next-self-hosted-app_build-1  |  ✓ Generating static pages (4/4)
next-self-hosted-app_build-1  |    Finalizing page optimization ...
next-self-hosted-app_build-1  |    Collecting build traces ...
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  | Route (app)                              Size     First Load JS
next-self-hosted-app_build-1  | ┌ ○ /                                    142 B          87.1 kB
next-self-hosted-app_build-1  | └ ○ /_not-found                          872 B          87.9 kB
next-self-hosted-app_build-1  | + First Load JS shared by all            87 kB
next-self-hosted-app_build-1  |   ├ chunks/354-67999519566e6594.js       31.5 kB
next-self-hosted-app_build-1  |   ├ chunks/41f0bf82-88862f34f87ffcaf.js  53.6 kB
next-self-hosted-app_build-1  |   └ other shared chunks (total)          1.84 kB
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  | ○  (Static)  prerendered as static content
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1  |
next-self-hosted-app_build-1 exited with code 0
next-self-hosted-app-1        |   ▲ Next.js 14.2.5
next-self-hosted-app-1        |   - Local:        http://localhost:3000
next-self-hosted-app-1        |
next-self-hosted-app-1        |
next-self-hosted-app-1        |  ✓ Starting...
next-self-hosted-app-1        |  ✓ Ready in 181ms
Enter fullscreen mode Exit fullscreen mode

You then will be able to see the running app:

The app running locally

The app running locally

Production Docker Compose File

We will be running the production application using Docker Compose as well. It is going to use the same runner+build container combination as in docker-compose.yml. The main difference is that there also will be a reverse proxy container that will stand between the Internet and the application.

Create a docker-compose.production.yml file:

services:
  # The reverse proxy - the main entrypoint into the application. Holds the TLS
  # certificates.
  nginx:
    image: "nginx:1.27.0-bookworm"
    volumes:
      - ./nginx/production.conf:/etc/nginx/nginx.conf
    command: ["nginx", "-g", "daemon off;"]
    restart: always
    ports:
      - 80:80
    networks:
      - public
    depends_on:
      app:
        condition: service_started
    healthcheck:
      test: ["CMD", "service", "nginx", "status"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 10s
      start_interval: 1s
  # The main application service
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    env_file:
      - .env.production
    volumes:
      - "app_build:/app/.next"
    command: ["yarn", "start"]
    restart: always
    networks:
      - public
      - internal
    depends_on:
      app_build:
        condition: service_completed_successfully
  # The one-off container that builds the application
  app_build:
    build:
      context: .
      dockerfile: Dockerfile
      target: production
    env_file:
      - .env.production
    volumes:
      - "app_build:/app/.next"
    command: ["yarn", "build"]
volumes:
  # The volume that is going to store the .next directory where the built
  # application is located
  app_build: {}
networks:
  # The network for services to which NGINX is connected, meant for services
  # that have to be exposed to the outside (e.g. the Next.js application or an
  # API server).
  public: {}
  # The network for services that are not meant to be exposed to the outside
  # e.g. Postgres database, Redis cache.
  internal: {}
Enter fullscreen mode Exit fullscreen mode

We will also create a custom NGINX config file nginx/production.conf that will forward all traffic from port 80 on the host to port 3000 in the Next.js app:

user nginx;
pid /var/run/nginx.pid;
worker_processes auto;
events {
  worker_connections 1024;
}

http {
  log_format json_combined escape=json
    '{'
      '"request_id":"$request_id",'
      '"host":"$host",'
      '"time":"$time_iso8601",'
      '"x_forwarded_for":"$http_x_forwarded_for",'
      '"remote_addr":"$remote_addr",'
      '"remote_user":"$remote_user",'
      '"request":"$request",'
      '"status": "$status",'
      '"body_bytes_sent":"$body_bytes_sent",'
      '"http_referrer":"$http_referer",'
      '"http_user_agent":"$http_user_agent",'
      '"request_time":"$request_time"'
    '}';

  access_log /var/log/nginx/access.log json_combined;
  error_log  /var/log/nginx/error.log warn;

  include /etc/nginx/mime.types;
  default_type application/octet-stream;
  sendfile on;
  keepalive_timeout 65;

  proxy_set_header X-Request-Id $request_id;

  add_header X-Request-Id $request_id;
  add_header X-Request-Time $request_time;

  server {
    listen 80;

    location / {
      client_max_body_size 1M;

      proxy_pass http://app:3000;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We don’t need the nginx directory in the application Docker image, so let’s add it to the .dockerignore file:

# Nginx config
nginx
Enter fullscreen mode Exit fullscreen mode

Push your code to GitHub

Create a new GitHub repository:

GitHub UI – creating a new repository

GitHub UI – creating a new repository

Add it as a new remote to your local one (git@github.com:prutya/next-self-hosted.git will be different for you, of course):

git remote add origin git@github.com:prutya/next-self-hosted.git
Enter fullscreen mode Exit fullscreen mode

Push the changes:

git push origin main
Enter fullscreen mode Exit fullscreen mode

Check that the repository has been updated:

GitHub UI – the repository

GitHub UI – the repository

Now we need some server to run the app.

Buy a VPS

Create a new project on Hetzner:

Hetzner UI – adding a new project

Hetzner UI – adding a new project

Add a new server to the project:

Hetzner UI – adding a new server

Hetzner UI – adding a new server

Select "Docker CE" as the Image:

Hetzner UI – server image selection

Hetzner UI – server image selection

Select the CX22 Type for the node:

Hetzner UI – server type selection

Hetzner UI – server type selection

Add your public SSH key so that you can ssh into the VPS:

Hetzner UI – adding an SSH key

Hetzner UI – adding an SSH key

Finalize the setup by clicking “Create & Buy now”

Hetzner UI – the final step of server creation

Hetzner UI – the final step of server creation

This will create a new server on Hetzner.

Generate an SSH Key for the VPS

Connect to the VPS (your VPS IP address will be different):

ssh root@5.75.157.116
Enter fullscreen mode Exit fullscreen mode
The authenticity of host '5.75.157.116 (5.75.157.116)' can't be established.
ED25519 key fingerprint is SHA256:oaPM2CQ4J0a4626sy/0jksB2eNhNBg2fA0pYwFASW7w.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '5.75.157.116' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-38-generic x86_64)
...
root@next-self-hosted:~#
Enter fullscreen mode Exit fullscreen mode

Generate a new SSH key that will identify the VPS:

ssh-keygen -t ed25519 -C ""
Enter fullscreen mode Exit fullscreen mode
Generating public/private ed25519 key pair.
Enter file in which to save the key (/root/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /root/.ssh/id_ed25519
Your public key has been saved in /root/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:V2Z6KJMoEMH3yBS9XlV3TYKkwiD4hYr5gJR/S5P9N74
The key's randomart image is:
+--[ED25519 256]--+
| .+oo+.    oo.o.+|
| oo.+.oo  .... o.|
|ooo* +o.o.. +    |
|= .o+=oo.o *     |
| o  +.+.S + .    |
|  .  o.  = +     |
|          o .    |
|           .     |
|           E.    |
+----[SHA256]-----+
Enter fullscreen mode Exit fullscreen mode
cat /root/.ssh/id_ed25519.pub
Enter fullscreen mode Exit fullscreen mode
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPUfdcXNykmjIU9BGBKk/GLEL27srCtbJPPSDETqkQMV
Enter fullscreen mode Exit fullscreen mode

Add the public part to the list of Deploy keys in the GitHub repository:

GitHub UI – adding a Deploy key

GitHub UI – adding a Deploy key

With this configurations in place, the VPS should be able to pull changes from the GitHub repository.

Pull your code to the VPS

Now the VPS should be able to clone the repository:

git clone git@github.com:prutya/next-self-hosted.git
Enter fullscreen mode Exit fullscreen mode
Cloning into 'next-self-hosted'...
The authenticity of host 'github.com (140.82.121.3)' can't be established.
ED25519 key fingerprint is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'github.com' (ED25519) to the list of known hosts.
remote: Enumerating objects: 37, done.
remote: Counting objects: 100% (37/37), done.
remote: Compressing objects: 100% (18/18), done.
remote: Total 37 (delta 15), reused 37 (delta 15), pack-reused 0
Receiving objects: 100% (37/37), 8.31 KiB | 4.16 MiB/s, done.
Resolving deltas: 100% (15/15), done.
Enter fullscreen mode Exit fullscreen mode

Verify by changing into the repo directory and listing the files:

cd next-self-hosted
ls -la
Enter fullscreen mode Exit fullscreen mode
total 76
drwxr-xr-x 5 root root 4096 Jul 26 06:30 .
drwx------ 5 root root 4096 Jul 26 06:30 ..
drwxr-xr-x 2 root root 4096 Jul 26 06:30 app
-rw-r--r-- 1 root root 1696 Jul 26 06:30 docker-compose.production.yml
-rw-r--r-- 1 root root  671 Jul 26 06:30 docker-compose.yml
-rw-r--r-- 1 root root 1450 Jul 26 06:30 Dockerfile
-rw-r--r-- 1 root root  650 Jul 26 06:30 .dockerignore
-rw-r--r-- 1 root root  134 Jul 26 06:30 .editorconfig
drwxr-xr-x 8 root root 4096 Jul 26 06:30 .git
-rw-r--r-- 1 root root  142 Jul 26 06:30 .gitattributes
-rw-r--r-- 1 root root  476 Jul 26 06:30 .gitignore
drwxr-xr-x 2 root root 4096 Jul 26 06:30 nginx
-rw-r--r-- 1 root root    8 Jul 26 06:30 .node-version
-rw-r--r-- 1 root root  310 Jul 26 06:30 package.json
-rw-r--r-- 1 root root   19 Jul 26 06:30 README.md
-rw-r--r-- 1 root root 9894 Jul 26 06:30 yarn.lock
-rw-r--r-- 1 root root   48 Jul 26 06:30 .yarnrc.yml
Enter fullscreen mode Exit fullscreen mode

Create .env.production for storing the secrets:

touch .env.production
Enter fullscreen mode Exit fullscreen mode

The app is now ready to be started.

Start the app

Just like we did locally, let's start the app via compose CLI, this time we will use a different compose file though and we will run it in the background by using --detach:

docker compose --file docker-compose.production.yml up --build --detach nginx
Enter fullscreen mode Exit fullscreen mode

Navigate to the IP address of your VPS in browser:

The application running on VPS accessed via IP address

The application running on VPS accessed via IP address

Great, now let’s set configure our domain to point to the VPS.

Configure DNS

Let’s add a new DNS record for the domain. There needs to be an A record pointing to the IP address of the VPS.

Cloudflare UI – creating an A record for the domain

Cloudflare UI – creating an A record for the domain

Once updated, navigate to the domain to see that we can now access the app this way too:

The application running on VPS accessed via domain name

The application running on VPS accessed via domain name

However, the application is currently not secure. It is running on port 80 via plain HTTP protocol without encryption. This might be an issue if you want to process user data, because it will be vulnerable for MITM attacks.

Configure TLS (SSL)

In order for the app to use HTTPS, we will generate Origin Server certificates, add them to the VPS and update Cloudflare settings enabling the “Full (strict)” TLS mode. We will also enable the “Always Use HTTPS” setting and change the minimum allowed TLS version to v1.2.

Let’s go to the SSL/TLS → Origin Server section and create a new ECC certificate:

Cloudflare UI – generating a new Origin Server certificate

Cloudflare UI – generating a new Origin Server certificate

Cloudflare will generate the certificates and open the download page where you can copy the certificates:

Cloudflare UI – a new Origin Server certificate

Cloudflare UI – a new Origin Server certificate

Let’s store the private part in certs/cloudflare.key.pem and the public one in certs/cloudflare.cert.pem files:

File structure showing certificates location

File structure showing certificates location

Add the certs directory to .gitignore and .dockerignore files so that we don’t push them to the repository and don’t add them to the Docker image:

# Certificates
certs
Enter fullscreen mode Exit fullscreen mode

Create a certs directory on the VPS and copy the certificates there:

ssh root@5.75.157.116 -C "mkdir -p /root/next-self-hosted/certs"

scp ./certs/cloudflare.key.pem root@5.75.157.116:/root/next-self-hosted/certs
cloudflare.key.pem    100%  241     6.6KB/s   00:00

scp ./certs/cloudflare.cert.pem root@5.75.157.116:/root/next-self-hosted/certs
cloudflare.cert.pem    100% 1176    33.2KB/s   00:00

ssh root@5.75.157.116 -C "ls -la /root/next-self-hosted/certs"
total 16
drwxr-xr-x 2 root root 4096 Jul 26 07:05 .
drwxr-xr-x 6 root root 4096 Jul 26 07:05 ..
-rw-r--r-- 1 root root 1176 Jul 26 07:05 cloudflare.cert.pem
-rw-r--r-- 1 root root  241 Jul 26 07:05 cloudflare.key.pem
Enter fullscreen mode Exit fullscreen mode

Adjust the NGINX config server section to listen on port 443 instead of 80 and point it to the certificates:

server {
  listen 443 ssl;
  listen [::]:443 ssl;

  http2 on;

  ssl_session_cache    shared:SSL:1m;
  ssl_session_timeout  5m;

  ssl_ciphers  HIGH:!aNULL:!MD5;
  ssl_prefer_server_ciphers  on;

  ssl_certificate /certs/cloudflare.cert.pem;
  ssl_certificate_key /certs/cloudflare.key.pem;

  location / {
    client_max_body_size 1M;

    proxy_pass http://app:3000;
  }
}
Enter fullscreen mode Exit fullscreen mode

Adjust the docker-compose.production.yml so that the certs directory is mounted into the nginx container and the port 443 is exposed instead of 80:

nginx:
  image: "nginx:1.27.0-bookworm"
  volumes:
    - ./nginx/production.conf:/etc/nginx/nginx.conf
    - ./certs:/certs # Add certificates volume
  command: ["nginx", "-g", "daemon off;"]
  restart: always
  ports:
    - 443:443 # Expose port 443 instead of 80
  networks:
    - public
  depends_on:
    app:
      condition: service_started
  healthcheck:
    test: ["CMD", "service", "nginx", "status"]
    interval: 30s
    timeout: 5s
    retries: 5
    start_period: 10s
    start_interval: 1s
Enter fullscreen mode Exit fullscreen mode

Push the changes to the GitHub repo:

git add -A
git commit -m "Configure TLS"
git push origin main
Enter fullscreen mode Exit fullscreen mode

Stop the app on the VPS:

docker compose --file docker-compose.production.yml down -t 1
Enter fullscreen mode Exit fullscreen mode
[+] Running 6/6
 ✔ Container next-self-hosted-nginx-1      Removed                                                                                                                                                               0.2s
 ✔ Container next-self-hosted-app-1        Removed                                                                                                                                                               0.2s
 ✔ Container next-self-hosted-app_build-1  Removed                                                                                                                                                               0.0s
 ✔ Network next-self-hosted_internal       Removed                                                                                                                                                               0.2s
 ✔ Network next-self-hosted_public         Removed                                                                                                                                                               0.1s
 ✔ Network next-self-hosted_default        Removed 
Enter fullscreen mode Exit fullscreen mode

Pull the changes on the VPS:

git pull origin main
Enter fullscreen mode Exit fullscreen mode

Start the app again:

docker compose --file docker-compose.production.yml up --build --detach nginx
Enter fullscreen mode Exit fullscreen mode
[+] Running 6/6
 ✔ Network next-self-hosted_default        Created                                                                                                                                                               0.1s
 ✔ Network next-self-hosted_public         Created                                                                                                                                                               0.1s
 ✔ Network next-self-hosted_internal       Created                                                                                                                                                               0.1s
 ✔ Container next-self-hosted-app_build-1  Exited                                                                                                                                                               23.9s
 ✔ Container next-self-hosted-app-1        Started                                                                                                                                                              24.2s
 ✔ Container next-self-hosted-nginx-1      Started       
Enter fullscreen mode Exit fullscreen mode

Turn on Full (strict) TLS mode on Cloudflare:

Cloudflare UI – enabling Full (strict) mode

Cloudflare UI – enabling Full (strict) mode

Now if we try to access port 80 on the server it will return an error:

➜  next-self-hosted git:(main) curl -v http://next-self-hosted.click/
*   Trying 104.21.21.27:80...
* Connected to next-self-hosted.click (104.21.21.27) port 80 (#0)
> GET / HTTP/1.1
> Host: next-self-hosted.click
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 521
< Date: Fri, 26 Jul 2024 07:15:11 GMT
< Content-Type: text/plain; charset=UTF-8
< Content-Length: 15
< Connection: keep-alive
< Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=HevjuWN3sWeCpjOrLz9QnK6IVXPTNOirKR5Hvli5V0V5CiS60meW1uOdDTBzt%2BxadT92KEW%2FOXuc9wBVZXnpADy6fVRoNSVsEF4Zg%2Bx3J61qDJVPbtY%2Bu9EsPkLwfstemCcYbFYlspBz"}],"group":"cf-nel","max_age":604800}
< NEL: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
< X-Frame-Options: SAMEORIGIN
< Referrer-Policy: same-origin
< Cache-Control: private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0
< Expires: Thu, 01 Jan 1970 00:00:01 GMT
< Server: cloudflare
< CF-RAY: 8a9298deb8f6c31b-VIE
< alt-svc: h3=":443"; ma=86400
<
* Connection #0 to host next-self-hosted.click left intact
error code: 521%
Enter fullscreen mode Exit fullscreen mode

Let’s handle that error a bit more gracefully. Enable the “Always use HTTPS” toggle in the SSL/TLS → Edge Certificates section on Cloudflare:

Cloudflare UI – enabling the Always Use HTTPS toggle

Cloudflare UI – enabling the Always Use HTTPS toggle

While we are here, we can also set the Minimum TLS Version to v1.2:

Cloudflare UI – changing the Minimum TLS Version to v1.2

Cloudflare UI – changing the Minimum TLS Version to v1.2

Now if we try to access the port 80, Cloudflare will respond with code 301 Moved Permanently redirecting the user to the HTTPS port:

➜  next-self-hosted git:(main) curl -v http://next-self-hosted.click/
*   Trying 172.67.196.4:80...
* Connected to next-self-hosted.click (172.67.196.4) port 80 (#0)
> GET / HTTP/1.1
> Host: next-self-hosted.click
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 301 Moved Permanently
< Date: Fri, 26 Jul 2024 07:17:36 GMT
< Content-Type: text/html
< Content-Length: 167
< Connection: keep-alive
< Cache-Control: max-age=3600
< Expires: Fri, 26 Jul 2024 08:17:36 GMT
< Location: https://next-self-hosted.click/
< Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=FYFTNzALCb2tM6jP%2F79nGLE%2FpoztnjDmbmgeoZEaKofvmIExP3aX346OJuB90Tdiem5Q5wKRe%2FDKscdnfkXAqbtxHNKtMVNtlU2YH2%2B5103Dsn9SFnE5%2FHd%2FJfsWrhsya%2B8UXzoruUWo"}],"group":"cf-nel","max_age":604800}
< NEL: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
< Server: cloudflare
< CF-RAY: 8a929c690ac5c2b6-VIE
< alt-svc: h3=":443"; ma=86400
<
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
* Connection #0 to host next-self-hosted.click left intact
Enter fullscreen mode Exit fullscreen mode

We can now verify the app and see that it’s secure:

The application secure connection

The application secure connection

Let us now automate the process of deployment so that we don’t have to ssh in the VPS, pull the changes from GitHub and restart the app manually.

Automated deployment via GitHub Actions

We are going to generate an SSH key for GitHub Actions. The private key will be stored as Base64 in GitHub secrets, and the public one will be added to the list of authorized_hosts on the VPS. GitHub Actions will read the private key from the secret and use it to connect to the VPS to pull the recent changes and restart the app.

Generate an SSH key for GitHub, provide the ./id_ed25519_github as the name of the file to save the key:

ssh-keygen -t ed25519 -C ""

Generating public/private ed25519 key pair.
Enter file in which to save the key (/Users/anton/.ssh/id_ed25519): ./id_ed25519_github
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in ./id_ed25519_github
Your public key has been saved in ./id_ed25519_github.pub
The key fingerprint is:
SHA256:Awbwkyfcl4PediRceHSUE/ufnbk0lk+b+hl20u/OIW8
The key's randomart image is:
+--[ED25519 256]--+
|  ...    oo.+o   |
|   o + o.o..o.   |
|    * * *.. ..   |
|     * + +   .   |
|      . S .   .  |
|       . o     o*|
|             ..%*|
|              *E%|
|             .+X*|
+----[SHA256]-----+
Enter fullscreen mode Exit fullscreen mode

The private part will be stored in ./id_ed25519_github and the public – in ./id_ed25519_github.pub.

Encode the private part as Base64 and copy it to the clipboard:

cat id_ed25519_github | base64 | pbcopy
Enter fullscreen mode Exit fullscreen mode

Store it in GitHub Actions secret VPS_SSH_KEY:

GitHub UI - adding the VPS_SSH_KEY secret

GitHub UI - adding the VPS_SSH_KEY secret

Add the public part to VPS authorized_keys file:

ssh root@5.75.157.116 -C "echo \"$(cat id_ed25519_github.pub)\" >> ~/.ssh/authorized_keys"
Enter fullscreen mode Exit fullscreen mode

Verify the connection as if you were GitHub:

ssh -i id_ed25519_github root@5.75.157.116

Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-38-generic x86_64)
...
root@next-self-hosted:~#
Enter fullscreen mode Exit fullscreen mode

Delete the GitHub keys from your machine - you no longer need them:

rm id_ed25519_github*
Enter fullscreen mode Exit fullscreen mode

We are also going to need to add the VPS public keys to known_hosts of the SSH client in GitHub Actions.

ssh-keyscan 5.75.157.116 | base64 | pbcopy
# 5.75.157.116:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.4
# 5.75.157.116:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.4
# 5.75.157.116:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.4
# 5.75.157.116:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.4
# 5.75.157.116:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.4
Enter fullscreen mode Exit fullscreen mode

Paste the public keys in GitHub Actions secret VPS_SSH_HOST_KEYS:

GitHub UI - adding the VPS_SSH_HOST_KEYS secret

GitHub UI - adding the VPS_SSH_HOST_KEYS secret

Add VPS user name (root) as VPS_SSH_USERNAME secret:

GitHub UI - adding the VPS_SSH_USERNAME secret

GitHub UI - adding the VPS_SSH_USERNAME secret

Add VPS IP address as VPS_IP secret:

GitHub UI - adding the VPS_IP secret

GitHub UI - adding the VPS_IP secret

Create a script that will be triggered by GitHub Actions runner – scripts/production.sh:

#!/usr/bin/env bash

set -exo pipefail

# Build and run the latest version of the app
docker compose --file docker-compose.production.yml up --build --detach nginx

# Remove the unused containers
docker system prune --force
Enter fullscreen mode Exit fullscreen mode

Make it executable:

chmod +x scripts/production.sh
Enter fullscreen mode Exit fullscreen mode

Add the GitHub action .github/workflows/push-to-main.yml that decodes the SSH key for accessing the VPS and start the scripts/production.sh script in the background:

name: Push to main
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      # Clone the repo
      - name: Checkout
        uses: actions/checkout@v4
      - name: Prepare SSH key
        run: |
          echo "${{ secrets.VPS_SSH_KEY }}" | base64 -d > vps_key.pem
          chmod 0600 vps_key.pem
      - name: Prepare the known_hosts
        run: |
          mkdir ~/.ssh/ && touch ~/.ssh/known_hosts
          echo "${{ secrets.VPS_SSH_HOST_KEYS }}" | base64 -d >> ~/.ssh/known_hosts
      - name: Start the deployment of the latest version in the background
        run: |
          DATE=$(date "+%Y%m%d%H%M%S")

          ssh -i vps_key.pem "${{ secrets.VPS_SSH_USERNAME }}@${{ secrets.VPS_IP }}" bash -c "'
            set -eo pipefail

            cd app
            git pull origin main

            nohup ./production.sh > "deploy-${DATE}.log" 2> "deploy-${DATE}.log" &
          '"
Enter fullscreen mode Exit fullscreen mode

Push the changes and check GitHub Actions:

GitHub UI – deployment Action log

GitHub UI – deployment Action log

Verify the deployment on the VPS – you can check the deploy-.log file to see how it went:

ls -al
...
-rw-r--r-- 1 root root 5137 Jul 26 07:56 deploy-20240726075540.log
...
Enter fullscreen mode Exit fullscreen mode

Verify that the docker container is running:

docker ps -a

CONTAINER ID   IMAGE                   COMMAND                  CREATED              STATUS                        PORTS                                           NAMES
1d0cd6f89119   nginx:1.27.0-bookworm   "/docker-entrypoint.…"   About a minute ago   Up About a minute (healthy)   80/tcp, 0.0.0.0:443->443/tcp, :::443->443/tcp   next-self-hosted-nginx-1
5db52edde339   next-self-hosted-app    "docker-entrypoint.s…"   About a minute ago   Up About a minute             3000/tcp                                        next-self-hosted-app-1
Enter fullscreen mode Exit fullscreen mode

Test the automated deployment

Let’s change the text on the main page (app/page.js) and push a new commit:

export default function Page() {
  return <h1>Hello, Blog!</h1>;
}
Enter fullscreen mode Exit fullscreen mode

Push the changes:

git add app
git commit -m "Update page text"
git push origin main
Enter fullscreen mode Exit fullscreen mode

After some time, the changes should make it to the live app:

Updated main page of the application

Updated main page of the application

That’s it! This should give a decent start into running your own instance of Next.js.

If you want to further secure you deployment, please see the “Bonus” sections 😉

Bonus: Set up Firewall on Hetzner

In order to restrict access to the ports that are not required to run the app, we can configure a firewall on Hetzner.

Open the Firewalls section in the project:

Hetzner UI – Firewalls section

Hetzner UI – Firewalls section

Create new Firewall only allowing TCP traffic on port 22 and 443:

Hetzner UI – creating a new Firewall

Hetzner UI – creating a new Firewall

Make sure to apply it to the Server:

Hetzner UI – applying the Firewall to the Server

Hetzner UI – applying the Firewall to the Server

Done. Any other ports will no longer be accessible from outside the VPS.

Bonus: Set up Authenticated Origin Pulls (mTLS)

In order to only allow Cloudflare to connect to port 443, we can configure Authenticated Origin Pulls, also know as mutual TLS.

Download the Cloudflare certificate to the certs directory:

curl -o certs/authenticated_origin_pull_ca.pem https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem
Enter fullscreen mode Exit fullscreen mode

Copy it to the VPS:

scp ./certs/authenticated_origin_pull_ca.pem root@5.75.157.116:/root/next-self-hosted/certs/authenticated_origin_pull_ca.pem
Enter fullscreen mode Exit fullscreen mode

Configure NGINX server block:

ssl_verify_client on;
ssl_client_certificate /etc/nginx/certs/authenticated_origin_pull_ca.pem;
Enter fullscreen mode Exit fullscreen mode

Enable Authenticated Origin Pulls in Cloudflare settings:

Cloudflare UI – enabling Authenticated Origin Pulls

Cloudflare UI – enabling Authenticated Origin Pulls

After the changes are pushed, verify that the app is still working:

The main page of the application

The main page of the application

That’s it, now only Cloudflare will be able to access your Origin Server.

Top comments (0)