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.
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
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 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:
- HOST: Your VPS IP address
- USERNAME: Your VPS username
- SSH_KEY: Your private SSH key content
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