DB
ALPHA

Setting up multiple websites on a Hetzner VPS

Part 2 - CI / CD with GitHub Actions

Now that we have our vps up and running and our first static website has been deployed, we can think about a more real case scenario. Let’s say we have a frontend project connected to a GitHub repo. When we push a new commit on the main branch of our project, we should trigger a pipeline that leads to a production environment build. In this example we’ll use Astro as a frontend framework. We will deploy another static website, so we don’t have to mess around with SSR. We’ll see how to handle server side rendering for both Astro and Next.JS in the next chapters. Let’s focus on connecting our GitHub repo to our VPS.

Create an Astro website

We are not wasting time on Astro: we will just create the default project and deploy it. You can start from there and customize it as you like.

Terminal window
npm create astro@latest

Create a Dockerfile for your Astro project

# Dockerfile
FROM nginx:alpine
# Copy the built static files
COPY dist/ /usr/share/nginx/html/
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 8080

Create a NGINX config file

worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
include /etc/nginx/mime.types;
gzip on;
gzip_min_length 1000;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
error_page 404 /404.html;
location = /404.html {
root /usr/share/nginx/html;
internal;
}
location / {
try_files $uri $uri/index.html =404;
}
}
}

Create a .dockerignore file in your project root

.DS_Store
node_modules
.git
.env

Create a docker-compose configuration

This yml file will be very similar to the one we created in the previous episode, since we already have a dockerized environment

services:
astro-project:
build: .
container_name: astro-project
restart: unless-stopped
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.astro-project.rule=Host(`mywebsite.com`)"
- "traefik.http.routers.astro-project.entrypoints=websecure"
- "traefik.http.routers.astro-project.tls.certresolver=letsencrypt"
- "traefik.http.services.astro-project.loadbalancer.server.port=8080"
networks:
web:
external: true

Create an nginx.conf configuration file

worker_processes 1;
events {
worker_connections 1024;
}
http {
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
include /etc/nginx/mime.types;
gzip on;
gzip_min_length 1000;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
error_page 404 /404.html;
location = /404.html {
root /usr/share/nginx/html;
internal;
}
location / {
try_files $uri $uri/index.html =404;
}
}
}

Create site directory on our VPS

Based on our previous tutorial, we’ll use ~/docker/websites folder to host all our websites, so we can create a new folder for our project. This will contain our dist files and docker configuration

Terminal window
sudo mkdir ~/docker/websites/astro-project

Create a GitHub Action

GitHub actions are defined as a yaml file. Inside your Astro project create a file at this path: .github/workflows/deploy.yml And setup the yml file like this:

name: Deploy to Hetzner VPS
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build Astro website
run: npm run build
- name: deploy to server
uses: appleboy/[email protected]
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/docker/astro-site
git pull origin main
sudo docker compose down
sudo docker compose up -d --build

Create GitHub secrets

In your GitHub account page, go to your repository Settings > Secrets and Variables > Actions and create new Repository secrets:

If you set up a specific port, add

port: ${{ secrets.PORT}}

to your GitHub Action and create the PORT secret in your repository Settings

If your key has a passhprase, add

port: ${{ secrets.PASSPHRASE}}

to your GitHub Action and create the PASSPHRASE secret in your repository Settings

Zero downtime with blue/green deployment

If we want to achieve zero downtime during files transfers between the GitHub Action and our VPS, we can setup our docker-compose to handle two different services: the first one will be our blue service, initially disabled, while the second one will be our green service, enabled by default.

services:
astro-project-blue:
build: .
container_name: astro-project-blue
restart: unless-stopped
networks:
- web
labels:
- "traefik.enable=false" # Initially disabled
- "traefik.http.routers.astro-project.rule=Host(`my-website`)"
- "traefik.http.routers.astro-project.entrypoints=websecure"
- "traefik.http.routers.astro-project.tls.certresolver=letsencrypt"
- "traefik.http.services.astro-project.loadbalancer.server.port=8080"
astro-project-green:
build: .
container_name: astro-project-green
restart: unless-stopped
networks:
- web
labels:
- "traefik.enable=true" # Currently active
- "traefik.http.routers.astro-project.rule=Host(`my-website`)"
- "traefik.http.routers.astro-project.entrypoints=websecure"
- "traefik.http.routers.astro-project.tls.certresolver=letsencrypt"
- "traefik.http.services.astro-project.loadbalancer.server.port=8080"
networks:
web:
external: true

Inside our GitHub Action we should keep track of which service is currently active and start the inactive one, then health check it and if everything is ok we can stop the previous active service.

- name: Deploy with Blue-Green
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.PORT}}
script: |
cd /docker/websites/astro-project
# Determine which service is currently active
ACTIVE=$(docker compose ps -q astro-project-green)
if [ -n "$ACTIVE" ]; then
INACTIVE="blue"
ACTIVE_SERVICE="green"
else
INACTIVE="green"
ACTIVE_SERVICE="blue"
fi
echo "Deploying to $INACTIVE, current active: $ACTIVE_SERVICE"
# Build and start the inactive service
docker compose up -d --build astro-project-$INACTIVE
# Wait for health check (adjust sleep time as needed)
sleep 10
# Switch traffic by updating labels
docker compose stop astro-project-$ACTIVE_SERVICE
# Optional: Clean up old images
docker image prune -f