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.