Compare commits
2 Commits
6af9458ad4
...
bf5bb69409
| Author | SHA1 | Date | |
|---|---|---|---|
| bf5bb69409 | |||
| d9060ed6ea |
540
docs/deployment/hetzner-server-setup.md
Normal file
540
docs/deployment/hetzner-server-setup.md
Normal file
@@ -0,0 +1,540 @@
|
||||
# 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 1–12):**
|
||||
|
||||
- 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 13–15):**
|
||||
|
||||
- [ ] 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
|
||||
|
||||
```bash
|
||||
ssh root@91.99.65.229
|
||||
```
|
||||
|
||||
## Step 2: Create Non-Root User
|
||||
|
||||
Create a dedicated user with sudo privileges and copy the SSH key:
|
||||
|
||||
```bash
|
||||
# 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**):
|
||||
|
||||
```bash
|
||||
ssh samir@91.99.65.229
|
||||
```
|
||||
|
||||
## Step 3: System Update & Essential Packages
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
## Step 4: Firewall Configuration (UFW)
|
||||
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker samir
|
||||
```
|
||||
|
||||
Log out and back in for the group change:
|
||||
|
||||
```bash
|
||||
exit
|
||||
# Then: ssh samir@91.99.65.229
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
## Step 7: Gitea (Self-Hosted Git)
|
||||
|
||||
Create the Gitea directory and compose file:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/gitea && cd ~/gitea
|
||||
```
|
||||
|
||||
Create `docker-compose.yml` with `nano ~/gitea/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
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:
|
||||
|
||||
```bash
|
||||
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**:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
mkdir -p ~/apps
|
||||
cd ~/apps
|
||||
git clone http://localhost:3000/sboulahtit/orion.git
|
||||
cd orion
|
||||
```
|
||||
|
||||
## Step 10: Configure Production Environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
### Critical Production Values
|
||||
|
||||
Generate secrets:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
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):
|
||||
|
||||
```caddy
|
||||
api.wizard.lu {
|
||||
reverse_proxy localhost:8001
|
||||
}
|
||||
|
||||
git.wizard.lu {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
flower.wizard.lu {
|
||||
reverse_proxy localhost:5555
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl restart caddy
|
||||
```
|
||||
|
||||
Caddy automatically provisions Let's Encrypt SSL certificates.
|
||||
|
||||
After Caddy is working, remove the temporary firewall rules:
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
sudo chown -R 1000:1000 logs uploads exports
|
||||
```
|
||||
|
||||
### Celery workers restarting
|
||||
|
||||
Check logs for import errors:
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
git stash
|
||||
git pull
|
||||
git stash pop
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
docker compose --profile full logs -f api
|
||||
docker compose --profile full logs -f celery-worker
|
||||
```
|
||||
|
||||
### Restart services
|
||||
|
||||
```bash
|
||||
docker compose --profile full restart api
|
||||
```
|
||||
|
||||
### Update deployment
|
||||
|
||||
```bash
|
||||
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` |
|
||||
@@ -197,6 +197,7 @@ nav:
|
||||
- CloudFlare Setup: deployment/cloudflare.md
|
||||
- GitLab CI/CD: deployment/gitlab.md
|
||||
- Gitea CI/CD: deployment/gitea.md
|
||||
- Hetzner Server Setup: deployment/hetzner-server-setup.md
|
||||
- Environment Variables: deployment/environment.md
|
||||
- Stripe Integration: deployment/stripe-integration.md
|
||||
|
||||
|
||||
Reference in New Issue
Block a user