Files
orion/docs/deployment/hetzner-server-setup.md
Samir Boulahtit bf5bb69409
Some checks failed
CI / ruff (push) Failing after 1m34s
CI / pytest (push) Failing after 1s
CI / architecture (push) Failing after 7s
CI / dependency-scanning (push) Successful in 30s
CI / audit (push) Failing after 7s
CI / docs (push) Has been skipped
docs(deployment): update server setup guide with wizard.lu domain
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:27:15 +01:00

13 KiB
Raw Blame History

Hetzner Cloud Server Setup

Complete step-by-step guide for deploying Wizamart on a Hetzner Cloud VPS.

!!! info "Server Details" - Provider: Hetzner Cloud - OS: Ubuntu 24.04.3 LTS (upgraded to 24.04.4 after updates) - Architecture: aarch64 (ARM64) - IP: 91.99.65.229 - IPv6: 2a01:4f8:1c1a:b39c::1 - Disk: 37 GB - RAM: 4 GB - Auth: SSH key (configured via Hetzner Console) - Setup date: 2026-02-11

!!! success "Progress — 2026-02-11" Completed (Steps 112):

- Non-root user `samir` with SSH key
- Server hardened (UFW firewall, SSH root login disabled, fail2ban)
- Docker 29.2.1 & Docker Compose 5.0.2 installed
- Gitea running at `http://91.99.65.229:3000` (user: `sboulahtit`, repo: `orion`)
- Repository cloned to `~/apps/orion`
- Production `.env` configured with generated secrets
- Full Docker stack deployed (API, PostgreSQL, Redis, Celery worker/beat, Flower)
- Database migrated (76 tables) and seeded (admin, platforms, CMS, email templates)
- API verified at `http://91.99.65.229:8001/docs` and `/admin/login`

**Remaining (Steps 1315):**

- [ ] DNS: Point domain A records to `91.99.65.229`
- [ ] Caddy reverse proxy with auto-SSL
- [ ] Gitea Actions runner for CI/CD
- [ ] Remove temporary firewall rules (ports 3000, 8001)

Installed Software Versions

Software Version
Ubuntu 24.04.4 LTS
Kernel 6.8.0-100-generic (aarch64)
Docker 29.2.1
Docker Compose 5.0.2
PostgreSQL 15 (container)
Redis 7-alpine (container)
Python 3.11-slim (container)
Gitea latest (container)

Step 1: Initial Server Access

ssh root@91.99.65.229

Step 2: Create Non-Root User

Create a dedicated user with sudo privileges and copy the SSH key:

# Create user
adduser samir
usermod -aG sudo samir

# Copy SSH keys to new user
rsync --archive --chown=samir:samir ~/.ssh /home/samir

Verify by connecting as the new user (from a new terminal):

ssh samir@91.99.65.229

Step 3: System Update & Essential Packages

sudo apt update && sudo apt upgrade -y

sudo apt install -y \
    curl \
    git \
    wget \
    ufw \
    fail2ban \
    htop \
    unzip \
    make

Reboot if a kernel upgrade is pending:

sudo reboot

Step 4: Firewall Configuration (UFW)

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Verify:

sudo ufw status

Expected output:

Status: active

To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere

Step 5: Harden SSH

!!! warning "Before doing this step" Make sure you can SSH as samir from another terminal first! If you lock yourself out, you'll need to use Hetzner's console rescue mode.

sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart ssh    # Note: Ubuntu 24.04 uses 'ssh' not 'sshd'

Step 6: Install Docker & Docker Compose

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker samir

Log out and back in for the group change:

exit
# Then: ssh samir@91.99.65.229

Verify:

docker --version
docker compose version

Step 7: Gitea (Self-Hosted Git)

Create the Gitea directory and compose file:

mkdir -p ~/gitea && cd ~/gitea

Create docker-compose.yml with nano ~/gitea/docker-compose.yml:

services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: always
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=gitea-db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=<GENERATED_PASSWORD>
      - GITEA__server__ROOT_URL=http://91.99.65.229:3000/
      - GITEA__server__SSH_DOMAIN=91.99.65.229
      - GITEA__server__DOMAIN=91.99.65.229
      - GITEA__actions__ENABLED=true
    volumes:
      - gitea-data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:22"
    depends_on:
      gitea-db:
        condition: service_healthy

  gitea-db:
    image: postgres:15
    container_name: gitea-db
    restart: always
    environment:
      POSTGRES_DB: gitea
      POSTGRES_USER: gitea
      POSTGRES_PASSWORD: <GENERATED_PASSWORD>
    volumes:
      - gitea-db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U gitea"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  gitea-data:
  gitea-db-data:

Generate the database password with openssl rand -hex 16 and replace <GENERATED_PASSWORD> in both places.

Open the firewall for Gitea and start:

sudo ufw allow 3000/tcp
docker compose up -d
docker compose ps

Visit http://91.99.65.229:3000 and complete the setup wizard. Create an admin account (e.g. sboulahtit).

Then create a repository (e.g. orion).

Step 8: Push Repository to Gitea

From your local machine:

cd /home/samir/Documents/PycharmProjects/letzshop-product-import
git remote add gitea http://91.99.65.229:3000/sboulahtit/orion.git
git push gitea master

Step 9: Clone Repository on Server

mkdir -p ~/apps
cd ~/apps
git clone http://localhost:3000/sboulahtit/orion.git
cd orion

Step 10: Configure Production Environment

cp .env.example .env
nano .env

Critical Production Values

Generate secrets:

openssl rand -hex 32   # For JWT_SECRET_KEY
openssl rand -hex 16   # For database password
Variable How to Generate / What to Set
DEBUG False
DATABASE_URL postgresql://wizamart_user:YOUR_DB_PW@db:5432/wizamart_db
JWT_SECRET_KEY Output of openssl rand -hex 32
ADMIN_PASSWORD Strong password
USE_CELERY true
REDIS_URL redis://redis:6379/0
STRIPE_SECRET_KEY Your Stripe secret key (configure later)
STRIPE_PUBLISHABLE_KEY Your Stripe publishable key (configure later)
STRIPE_WEBHOOK_SECRET Your Stripe webhook secret (configure later)
STORAGE_BACKEND r2 (if using Cloudflare R2, configure later)

Also update the PostgreSQL password in docker-compose.yml (lines 9 and 40) to match.

Step 11: Deploy with Docker Compose

cd ~/apps/orion

# Create directories with correct permissions for the container user
mkdir -p logs uploads exports
sudo chown -R 1000:1000 logs uploads exports

# Start infrastructure first
docker compose up -d db redis

# Wait for health checks to pass
docker compose ps

# Build and start the full stack
docker compose --profile full up -d --build

Verify all services are running:

docker compose --profile full ps

Expected: api (healthy), db (healthy), redis (healthy), celery-worker (healthy), celery-beat (running), flower (running).

Step 12: Initialize Database

!!! note "PYTHONPATH required" The seed scripts need PYTHONPATH=/app set explicitly when running inside the container.

# Run migrations (use 'heads' for multi-branch Alembic)
docker compose --profile full exec -e PYTHONPATH=/app api python -m alembic upgrade heads

# Seed production data
docker compose --profile full exec -e PYTHONPATH=/app api python scripts/seed/init_production.py
docker compose --profile full exec -e PYTHONPATH=/app api python scripts/seed/init_log_settings.py
docker compose --profile full exec -e PYTHONPATH=/app api python scripts/seed/create_default_content_pages.py
docker compose --profile full exec -e PYTHONPATH=/app api python scripts/seed/create_platform_pages.py
docker compose --profile full exec -e PYTHONPATH=/app api python scripts/seed/seed_email_templates.py

Seeded Data Summary

Data Count
Admin users 1 (admin@wizamart.com)
Platforms 3 (OMS, Main, Loyalty+)
Admin settings 15
Subscription tiers 4 (Essential, Professional, Business, Enterprise)
Log settings 6
CMS pages 8 (About, Contact, FAQ, Shipping, Returns, Privacy, Terms, Homepage)
Email templates 17 (4 languages: en, fr, de, lb)

Step 13: DNS Configuration (TODO)

Before setting up Caddy, point your domain's DNS to the server. In your domain registrar's DNS settings, create A records:

Type Name Value TTL
A @ 91.99.65.229 300
A api 91.99.65.229 300
A git 91.99.65.229 300
A flower 91.99.65.229 300
AAAA @ 2a01:4f8:1c1a:b39c::1 300
AAAA api 2a01:4f8:1c1a:b39c::1 300
AAAA git 2a01:4f8:1c1a:b39c::1 300
AAAA flower 2a01:4f8:1c1a:b39c::1 300

!!! tip "DNS propagation" Set TTL to 300 (5 minutes) initially. DNS changes can take up to 24 hours to propagate globally, but usually complete within 30 minutes. Verify with: dig api.wizard.lu

Step 14: Reverse Proxy with Caddy (TODO)

Install Caddy:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
    | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
    | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy

Configure /etc/caddy/Caddyfile (replace wizard.lu with your actual domain):

api.wizard.lu {
    reverse_proxy localhost:8001
}

git.wizard.lu {
    reverse_proxy localhost:3000
}

flower.wizard.lu {
    reverse_proxy localhost:5555
}
sudo systemctl restart caddy

Caddy automatically provisions Let's Encrypt SSL certificates.

After Caddy is working, remove the temporary firewall rules:

sudo ufw delete allow 3000/tcp
sudo ufw delete allow 8001/tcp

Step 15: Gitea Actions Runner (TODO)

!!! warning "ARM64 architecture" This server is ARM64. Download the arm64 binary, not amd64.

mkdir -p ~/gitea-runner && cd ~/gitea-runner

# Download act_runner (ARM64 version)
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-arm64
chmod +x act_runner-linux-arm64

# Register (get token from Gitea Site Administration > Runners)
./act_runner-linux-arm64 register \
    --instance http://localhost:3000 \
    --token YOUR_RUNNER_TOKEN

# Start daemon
./act_runner-linux-arm64 daemon &

Step 16: Verify Full Deployment (TODO)

# All containers running
docker compose --profile full ps

# API health
curl http://localhost:8001/health

# Caddy proxy with SSL
curl https://api.wizard.lu/health

# Gitea
curl https://git.wizard.lu

Port Reference

Service Internal External Domain (via Caddy)
Wizamart API 8000 8001 api.wizard.lu
PostgreSQL 5432 5432 (internal only)
Redis 6379 6380 (internal only)
Flower 5555 5555 flower.wizard.lu
Gitea 3000 3000 git.wizard.lu
Caddy 80, 443 (reverse proxy)

Directory Structure on Server

~/
├── gitea/
│   └── docker-compose.yml       # Gitea + PostgreSQL
├── apps/
│   └── orion/                   # Wizamart application
│       ├── .env                 # Production environment
│       ├── docker-compose.yml   # App stack (API, DB, Redis, Celery)
│       ├── logs/                # Application logs
│       ├── uploads/             # User uploads
│       └── exports/             # Export files
└── gitea-runner/                # (TODO) CI/CD runner

Troubleshooting

Permission denied on logs

The Docker container runs as appuser (UID 1000). Host-mounted volumes need matching ownership:

sudo chown -R 1000:1000 logs uploads exports

Celery workers restarting

Check logs for import errors:

docker compose --profile full logs celery-worker --tail 30

Common cause: stale task module references in app/core/celery_config.py.

SSH service name on Ubuntu 24.04

Ubuntu 24.04 uses ssh not sshd:

sudo systemctl restart ssh    # correct
sudo systemctl restart sshd   # will fail

git pull fails with local changes

If docker-compose.yml was edited on the server (e.g. passwords), stash before pulling:

git stash
git pull
git stash pop

Maintenance

View logs

docker compose --profile full logs -f api
docker compose --profile full logs -f celery-worker

Restart services

docker compose --profile full restart api

Update deployment

cd ~/apps/orion
git stash          # Save local config changes
git pull
git stash pop      # Re-apply local config
docker compose --profile full up -d --build
docker compose --profile full exec -e PYTHONPATH=/app api python -m alembic upgrade heads

Quick access URLs (current — no domain yet)

Service URL
API Swagger docs http://91.99.65.229:8001/docs
API ReDoc http://91.99.65.229:8001/redoc
Admin panel http://91.99.65.229:8001/admin/login
Health check http://91.99.65.229:8001/health
Gitea http://91.99.65.229:3000
Flower http://91.99.65.229:5555