I recently deployed this blog on my own Hetzner VPS using Docker. The goal was to keep the setup simple, but still close to a real production workflow.

This post covers the stack I used for the blog deployment: Docker Compose, Apache + PHP, MySQL, Traefik, Let’s Encrypt, Cloudflare, and GitHub Actions for automatic deployment after each push to the main branch.

Tech stack:

  • Docker + Docker Compose
  • Apache + PHP 8.4
  • MySQL 8.4
  • Traefik reverse proxy
  • Let’s Encrypt SSL
  • Cloudflare DNS/proxy with Full Strict SSL
  • GitHub Actions auto-deployment
  • UFW + Fail2ban for VPS security

App + database services:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: kurdibuilds_app
    restart: unless-stopped
    env_file:
      - .env
    depends_on:
      - db
    networks:
      - proxy
      - internal
    volumes:
      - app_storage:/var/www/html/storage
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      - "traefik.http.routers.kurdibuilds.rule=Host(`kurdibuilds.dev`) || Host(`www.kurdibuilds.dev`)"
      - "traefik.http.routers.kurdibuilds.entrypoints=websecure"
      - "traefik.http.routers.kurdibuilds.tls.certresolver=letsencrypt"
      - "traefik.http.services.kurdibuilds.loadbalancer.server.port=80"

  db:
    image: mysql:8.4
    container_name: kurdibuilds_db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - internal

volumes:
  db_data:
  app_storage:

networks:
  proxy:
    external: true
  internal:
    internal: true

Deploy script:

#!/bin/bash
set -e

cd /opt/stacks/apps/kurdibuilds-blog

echo "Pulling latest code..."
git pull origin main

echo "Building app image..."
docker compose -f docker-compose.prod.yml build app

echo "Restarting app..."
docker compose -f docker-compose.prod.yml up -d --force-recreate app

echo "Running Laravel commands..."
docker exec kurdibuilds_app php artisan optimize:clear
docker exec kurdibuilds_app php artisan migrate --force
docker exec kurdibuilds_app php artisan config:cache
docker exec kurdibuilds_app php artisan route:cache
docker exec kurdibuilds_app php artisan view:cache

echo "Cleaning old images..."
docker image prune -f

echo "Deployment finished."

GitHub Actions:

name: Deploy to Production

on:
  push:
    branches:
      - main

concurrency:
  group: production-deploy
  cancel-in-progress: true

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Deploy on VPS
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            /opt/stacks/apps/kurdibuilds-blog/deploy.sh

Production secrets are stored only on the VPS and in GitHub Actions secrets, not in the repository.