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
- Install Docker (or Docker Desktop)
- Create a GitHub account
- Buy a domain – you can find a ~1€ one at Namecheap, just go to the “$2 or less” section. I got the next-self-hosted.click domain for this tutorial for just 1.07€
- Create a Cloudflare account
- Add your domain to Cloudflare
- Create a Hetzner account
- Add Next.js to your project
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
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 /app/.yarn /app/.yarn
# Copy the rest of the application files
COPY . .
# 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
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
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
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: {}
Now let’s start the app:
docker compose up --build app
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
You then will be able to see the running app:
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: {}
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;
}
}
}
We don’t need the nginx directory in the application Docker image, so let’s add it to the .dockerignore file:
# Nginx config
nginx
Push your code to GitHub
Create a new GitHub repository:
Add it as a new remote to your local one ([email protected]:prutya/next-self-hosted.git will be different for you, of course):
git remote add origin [email protected]:prutya/next-self-hosted.git
Push the changes:
git push origin main
Check that the repository has been updated:
Now we need some server to run the app.
Buy a VPS
Create a new project on Hetzner:
Add a new server to the project:
Select “Docker CE” as the Image:
Select the CX22 Type for the node:
Add your public SSH key so that you can ssh into the VPS:
Finalize the setup by clicking “Create & Buy now”
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):
➜ next-self-hosted git:(main) ssh [email protected]
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:~#
Generate a new SSH key that will identify the VPS:
root@next-self-hosted:~# ssh-keygen -t ed25519 -C ""
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]-----+
root@next-self-hosted:~# cat /root/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPUfdcXNykmjIU9BGBKk/GLEL27srCtbJPPSDETqkQMV
Add the public part to the list of Deploy keys in the GitHub repository:
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:
root@next-self-hosted:~# git clone [email protected]:prutya/next-self-hosted.git
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.
Verify by changing into the repo directory and listing the files:
root@next-self-hosted:~# cd next-self-hosted/
root@next-self-hosted:~/next-self-hosted# ls -al
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
Create .env.production for storing the secrets:
root@next-self-hosted:~/next-self-hosted# touch .env.production
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
Navigate to the IP address of your VPS in browser:
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.
Once updated, navigate to the domain to see that we can now access the app this way too:
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 will generate the certificates and open the download page where you can copy the certificates:
Let’s store the private part in certs/cloudflare.key.pem and the public one in certs/cloudflare.cert.pem files:
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
Create a certs directory on the VPS and copy the certificates there:
ssh [email protected] -C "mkdir -p /root/next-self-hosted/certs"
scp ./certs/cloudflare.key.pem [email protected]:/root/next-self-hosted/certs
cloudflare.key.pem 100% 241 6.6KB/s 00:00
scp ./certs/cloudflare.cert.pem [email protected]:/root/next-self-hosted/certs
cloudflare.cert.pem 100% 1176 33.2KB/s 00:00
ssh [email protected] -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
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;
}
}
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
Push the changes to the GitHub repo:
git add -A
git commit -m "Configure TLS"
git push origin main
Stop the app on the VPS:
docker compose --file docker-compose.production.yml down -t 1
[+] 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
Pull the changes on the VPS:
git pull origin main
Start the app again
docker compose --file docker-compose.production.yml up --build --detach nginx
[+] 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 24.5s
Turn on Full (strict) TLS mode on Cloudflare:
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%
Let’s handle that error a bit more gracefully. Enable the “Always use HTTPS” toggle in the SSL/TLS → Edge Certificates section on Cloudflare:
While we are here, we can also set 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
We can now verify the app and see that it’s secure:
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]-----+
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
Store it in GitHub Actions secret VPS_SSH_KEY:
Add the public part to VPS authorized_keys file:
ssh [email protected] -C "echo \"$(cat id_ed25519_github.pub)\" >> ~/.ssh/authorized_keys"
Verify the connection as if you were GitHub:
ssh -i id_ed25519_github [email protected]
Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-38-generic x86_64)
...
root@next-self-hosted:~#
Delete the GitHub keys from your machine - you no longer need them:
rm id_ed25519_github*
We are also going to need to add the VPS public keys to known_hosts of the SSH client in GitHub actions.
Use ssh-keyscan to get the keys, encode them as Base64 and copy to the clipboard:
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
Paste the public keys in GitHub Actions secret VPS_SSH_HOST_KEYS:
Add VPS user name (root) as VPS_SSH_USERNAME secret:
Add VPS IP address as 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
Make it executable:
chmod +x scripts/production.sh
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" &
'"
Push the changes and check GitHub Actions:
Verify the deployment on the VPS – you can check the deploy-<DATE>.log file to see how it went:
root@next-self-hosted:~/next-self-hosted# ls -al
total 96
drwxr-xr-x 8 root root 4096 Jul 26 07:55 .
drwx------ 6 root root 4096 Jul 26 07:55 ..
drwxr-xr-x 2 root root 4096 Jul 26 06:30 app
drwxr-xr-x 2 root root 4096 Jul 26 07:05 certs
-rw-r--r-- 1 root root 5137 Jul 26 07:56 deploy-20240726075540.log
-rw-r--r-- 1 root root 1696 Jul 26 07:12 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 669 Jul 26 07:55 .dockerignore
-rw-r--r-- 1 root root 134 Jul 26 06:30 .editorconfig
-rw-r--r-- 1 root root 0 Jul 26 06:37 .env.production
drwxr-xr-x 8 root root 4096 Jul 26 07:55 .git
-rw-r--r-- 1 root root 142 Jul 26 06:30 .gitattributes
drwxr-xr-x 3 root root 4096 Jul 26 07:55 .github
-rw-r--r-- 1 root root 476 Jul 26 07:12 .gitignore
drwxr-xr-x 2 root root 4096 Jul 26 07:12 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
drwxr-xr-x 2 root root 4096 Jul 26 07:55 scripts
-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
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
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>;
}
Push the changes:
git add app
git commit -m "Update page text"
git push origin main
After some time, the changes should make it to the live app:
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:
Create new Firewall only allowing TCP traffic on port 22 and 443:
Make sure to apply it 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
Copy it to the VPS:
scp ./certs/authenticated_origin_pull_ca.pem [email protected]:/root/next-self-hosted/certs/authenticated_origin_pull_ca.pem
Configure NGINX server block:
ssl_verify_client on;
ssl_client_certificate /etc/nginx/certs/authenticated_origin_pull_ca.pem;
Enable Authenticated Origin Pulls in Cloudflare settings:
After the changes are pushed, verify that the app is still working:
That’s it, now only Cloudflare will be able to access your Origin Server.