Deployment (Laravel Forge + Hetzner)
Step-by-step runbook to deploy the Hatched platform to a Hetzner VPS via Laravel Forge.
This document is a step-by-step guide for deploying the Hatched platform to a Hetzner VPS managed by Laravel Forge.
Architecture Overview
┌─────────────────────────────────────────────┐
│ Hetzner VPS (Forge) │
│ │
hatched.live ────►│ Nginx (:443) │
│ ├── / → Dashboard (:3001) │
│ ├── /api/v1/* → API (:3000) │
│ └── /docs → Swagger (:3000) │
│ │
│ Daemons: │
│ ├── API Server (node dist/main.js) │
│ ├── Worker (node dist/worker.js) │
│ └── Dashboard (next start :3001) │
│ │
│ PostgreSQL 16 (:5432) │
│ Redis 7 (:6379) │
└─────────────────────────────────────────────┘
cdn.hatched.live ──► Cloudflare R2 (Widget JS bundles)4 processes will run: API, Worker, Dashboard (Nginx is already managed by Forge).
Step 1: Create the Hetzner Server
Add the Server in Forge
- Forge > Servers > Create Server
- Provider: Hetzner Cloud (API key required)
- Recommended plans:
| Environment | Hetzner Size | CPU | RAM | Disk | Price |
|---|---|---|---|---|---|
| Staging | CX22 | 2 vCPU | 4 GB | 40 GB | ~€4/mo |
| Production | CX32 | 4 vCPU | 8 GB | 80 GB | ~€8/mo |
- Region: Nuremberg (eu-central) or Helsinki (eu-north)
- Server Type: App Server (Nginx included)
- Database: PostgreSQL 16 (Forge installs automatically)
- PHP: Not needed — we only use Node.js (Forge installs PHP anyway, which is fine)
Once Forge finishes provisioning, it configures SSH access, the firewall, automatic security updates, and Let's Encrypt.
Step 2: Post-Provisioning Setup
After Forge has created the server, SSH in:
ssh forge@SERVER_IP2.1 Install Node.js 20 LTS
# Forge doesn't ship Node by default; install via NVM
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install 20
nvm alias default 20
node -v # v20.x.x2.2 Install pnpm
corepack enable
corepack prepare pnpm@9.15.4 --activate
pnpm -v # 9.15.42.3 Install Redis (via Forge)
Forge > Server > Redis tab > Install Redis
- Forge installs and manages Redis automatically
- Default port: 6379, localhost only
- Password: visible in the Forge panel (recommended: leave passwordless since it binds to localhost only)
2.4 Create the PostgreSQL Database (via Forge)
Forge > Server > Database tab:
- Add Database:
hatched - Add Database User:
hatched(set a password and save it) - Forge already installed PostgreSQL during provisioning
Smoke test:
psql -U hatched -d hatched -h localhost -c "SELECT 1;"Step 3: Create the Site in Forge
3.1 API + Dashboard Site
Forge > Sites > Add Site:
- Domain:
hatched.live(or your own domain) - Project Type: Static HTML (we'll override the Nginx config afterwards)
- Web Directory:
/public(irrelevant; we'll override it)
3.2 SSL Certificate
Site > SSL > Let's Encrypt > Obtain Certificate
3.3 Connect the Git Repository
Site > Git Repository:
- Provider: GitHub
- Repository:
your-org/hatched - Branch:
main - ✅ Install Composer Dependencies: UNCHECKED
- ✅ Install Node Dependencies: UNCHECKED (we'll write our own deploy script)
Step 4: Environment Variables
Forge > Site > Environment tab — paste the .env:
# ─── Node ──────────────────────────────────
NODE_ENV=production
# ─── Database ──────────────────────────────
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=hatched
DB_USER=hatched
DB_PASSWORD=PASSWORD_YOU_SET_IN_FORGE
DATABASE_URL=postgresql://hatched:PASSWORD@127.0.0.1:5432/hatched
# ─── Redis ─────────────────────────────────
REDIS_URL=redis://127.0.0.1:6379
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# ─── API ───────────────────────────────────
API_PORT=3000
API_PREFIX=/api/v1
CORS_ORIGINS=https://hatched.live,https://www.hatched.live
# ─── Dashboard ─────────────────────────────
DASHBOARD_PORT=3001
NEXT_PUBLIC_API_URL=https://hatched.live/api/v1
# ─── JWT & Auth ────────────────────────────
JWT_SECRET=PUT_A_LONG_64_CHAR_RANDOM_STRING_HERE
JWT_EXPIRES_IN=24h
EMBED_TOKEN_SECRET=ANOTHER_64_CHAR_RANDOM_STRING
INTERNAL_SERVICE_TOKEN=ANOTHER_64_CHAR_RANDOM_STRING
WIDGET_CORS_ORIGINS=https://hatched.live,https://www.flalingo.com
# ─── Image Generation ─────────────────────
FAL_KEY=fal_key_xxxxxxxxxxxx
REPLICATE_API_TOKEN=r8_xxxxxxxxxxxx
IMAGE_PRIMARY_PROVIDER=fal
IMAGE_FALLBACK_PROVIDER=replicate
IMAGE_GENERATE_MODEL=fal-ai/nano-banana-2
IMAGE_EDIT_MODEL=fal-ai/flux-kontext/pro
IMAGE_EVOLVE_MODEL=fal-ai/nano-banana-2
# ─── Storage (Cloudflare R2) ──────────────
R2_ACCOUNT_ID=xxxxxxxxxxxx
R2_ACCESS_KEY_ID=xxxxxxxxxxxx
R2_SECRET_ACCESS_KEY=xxxxxxxxxxxx
R2_BUCKET_NAME=hatched-images
CDN_BASE_URL=https://cdn.hatched.live
# ─── Stripe ────────────────────────────────
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
STRIPE_GROWTH_PRICE_ID=price_xxxxxxxxxxxx
STRIPE_PRO_PRICE_ID=price_xxxxxxxxxxxx
STRIPE_SUCCESS_URL=https://hatched.live/dashboard/settings?billing=success
STRIPE_CANCEL_URL=https://hatched.live/dashboard/settings?billing=cancelled
STRIPE_RETURN_URL=https://hatched.live/dashboard/settingsGenerate secrets with:
openssl rand -hex 32
Step 5: Deploy Script
Forge > Site > Deploy Script (replace the existing script entirely):
cd /home/forge/hatched.live
# ─── Load env ──────────────────────────────
set -a
source .env
set +a
# ─── Fetch source ──────────────────────────
git pull origin $FORGE_SITE_BRANCH
# ─── Install deps ──────────────────────────
export PATH="$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ | tail -1)/bin:$PATH"
pnpm install --frozen-lockfile
# ─── Build shared package first (others depend on it) ───
pnpm --filter @hatched/shared build
# ─── Build API ─────────────────────────────
pnpm --filter @hatched/api build
# ─── Build Dashboard ──────────────────────
pnpm --filter @hatched/dashboard build
# ─── Build Widgets ────────────────────────
pnpm --filter @hatched/widgets build
# ─── DB migrations ────────────────────────
cd apps/api
pnpm db:migrate
cd /home/forge/hatched.live
# ─── Restart daemons ──────────────────────
# Forge restarts daemons automatically (see Step 6).
# On the first deploy, daemons may not exist yet, so this is best-effort.
sudo supervisorctl restart hatched-api 2>/dev/null || true
sudo supervisorctl restart hatched-worker 2>/dev/null || true
sudo supervisorctl restart hatched-dashboard 2>/dev/null || true
echo "✓ Deploy finished"Enable Quick Deploy (optional)
Site > Quick Deploy: Enable
This triggers an automatic deploy on every push to main.
Step 6: Daemon (Process) Definitions
Forge > Server > Daemons tab — add 3 daemons:
6.1 API Server
| Field | Value |
|---|---|
| Command | /home/forge/.nvm/versions/node/v20.*/bin/node /home/forge/hatched.live/apps/api/dist/main.js |
| User | forge |
| Directory | /home/forge/hatched.live/apps/api |
| Processes | 1 |
| Start Seconds | 5 |
Note: Forge runs daemons under supervisord; crashes are restarted automatically.
6.2 Worker Process
| Field | Value |
|---|---|
| Command | /home/forge/.nvm/versions/node/v20.*/bin/node /home/forge/hatched.live/apps/api/dist/worker.js |
| User | forge |
| Directory | /home/forge/hatched.live/apps/api |
| Processes | 1 |
| Start Seconds | 5 |
6.3 Dashboard (Next.js)
| Field | Value |
|---|---|
| Command | /home/forge/.nvm/versions/node/v20.*/bin/node /home/forge/hatched.live/apps/dashboard/node_modules/.bin/next start --port 3001 |
| User | forge |
| Directory | /home/forge/hatched.live/apps/dashboard |
| Processes | 1 |
| Start Seconds | 5 |
Important: supervisord does not source the
.envfile for daemon env vars. We use wrapper scripts to inject them.
6.4 Daemon Wrapper Scripts
SSH in and create these files:
# API wrapper
cat > /home/forge/hatched.live/start-api.sh << 'EOF'
#!/bin/bash
set -a
source /home/forge/hatched.live/.env
set +a
export PATH="$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ | tail -1)/bin:$PATH"
cd /home/forge/hatched.live/apps/api
exec node dist/main.js
EOF
# Worker wrapper
cat > /home/forge/hatched.live/start-worker.sh << 'EOF'
#!/bin/bash
set -a
source /home/forge/hatched.live/.env
set +a
export PATH="$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ | tail -1)/bin:$PATH"
cd /home/forge/hatched.live/apps/api
exec node dist/worker.js
EOF
# Dashboard wrapper
cat > /home/forge/hatched.live/start-dashboard.sh << 'EOF'
#!/bin/bash
set -a
source /home/forge/hatched.live/.env
set +a
export PATH="$HOME/.nvm/versions/node/$(ls $HOME/.nvm/versions/node/ | tail -1)/bin:$PATH"
cd /home/forge/hatched.live/apps/dashboard
exec node node_modules/.bin/next start --port 3001
EOF
chmod +x /home/forge/hatched.live/start-*.shThen update the daemon Commands in Forge:
| Daemon | Command |
|---|---|
| hatched-api | bash /home/forge/hatched.live/start-api.sh |
| hatched-worker | bash /home/forge/hatched.live/start-worker.sh |
| hatched-dashboard | bash /home/forge/hatched.live/start-dashboard.sh |
Step 7: Nginx Configuration
Forge > Site > Nginx Configuration (Edit):
Replace the entire config with:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name hatched.live www.hatched.live;
# SSL — managed by Forge
ssl_certificate /etc/nginx/ssl/hatched.live/server.crt;
ssl_certificate_key /etc/nginx/ssl/hatched.live/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 1000;
# ─── API Proxy (:3000) ─────────────────────
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
proxy_send_timeout 120s;
# Large bodies (image upload)
client_max_body_size 5m;
}
# Swagger docs
location /docs {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# OpenAPI JSON/YAML
location ~ ^/openapi\.(json|yaml)$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}
# ─── Dashboard Proxy (:3001) ───────────────
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Next.js static assets
location /_next/static/ {
proxy_pass http://127.0.0.1:3001;
expires 365d;
add_header Cache-Control "public, immutable";
}
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name hatched.live www.hatched.live;
return 301 https://$host$request_uri;
}Confirm the SSL certificate paths match what Forge generated. Forge typically uses
/etc/nginx/ssl/hatched.live/.
Step 8: Widget CDN (Cloudflare R2)
Widget JS bundles are served from Cloudflare R2 (cdn.hatched.live).
8.1 Create the R2 Bucket
- Cloudflare Dashboard > R2 > Create Bucket:
hatched-widgets - Attach a custom domain:
cdn.hatched.live
8.2 Create an R2 API Token
Cloudflare Dashboard > R2 > Manage R2 API Tokens > Create Token:
- Permissions: Object Read & Write
- Bucket:
hatched-widgets - Save: Account ID, Access Key ID, Secret Access Key
8.3 Deploy Widgets
Widgets are built automatically during the build step. To upload to the CDN, either extend the deploy script or run manually:
# On the server
cd /home/forge/hatched.live
export R2_WIDGET_BUCKET=hatched-widgets
pnpm --filter @hatched/widgets deploy:cdnOr extend the deploy script (append to Step 5's script):
# Upload widgets to the CDN
export R2_WIDGET_BUCKET=hatched-widgets
cd /home/forge/hatched.live
pnpm --filter @hatched/widgets deploy:cdn 2>/dev/null || echo "⚠ Widget CDN deploy skipped (wrangler login may be required)"8.4 Wrangler Auth (first run)
# On the server
npx wrangler login
# OR use an API token:
export CLOUDFLARE_API_TOKEN=xxxxxxxxxxxxStep 9: DNS Records
Add these records at your DNS provider (Cloudflare, etc.):
| Type | Name | Value | Proxy |
|---|---|---|---|
| A | hatched.live | HETZNER_IP | Optional |
| A | www | HETZNER_IP | Optional |
| CNAME | cdn | R2 custom-domain value | Proxied (orange) |
Step 10: First Deploy
10.1 Run Migrations
ssh forge@SERVER_IP
cd /home/forge/hatched.live
source .env
cd apps/api
pnpm db:migrate10.2 Seed Data (optional)
cd /home/forge/hatched.live/apps/api
pnpm db:seed10.3 Start Daemons
Forge > Server > Daemons > Restart each of the 3 daemons.
10.4 Verify
# API health check
curl https://hatched.live/api/v1/health
# Swagger
# In a browser: https://hatched.live/docs
# Dashboard
# In a browser: https://hatched.live/login
# Inspect processes
sudo supervisorctl statusExpected output:
hatched-api RUNNING pid 12345, uptime 0:05:00
hatched-worker RUNNING pid 12346, uptime 0:05:00
hatched-dashboard RUNNING pid 12347, uptime 0:05:00Step 11: Monitoring & Logs
Log Files
# Supervisor logs (daemon stdout/stderr)
tail -f /home/forge/.forge/daemon-*.log
# Nginx access/error logs
tail -f /var/log/nginx/hatched.live-access.log
tail -f /var/log/nginx/hatched.live-error.log
# PostgreSQL logs
sudo tail -f /var/log/postgresql/postgresql-16-main.logForge Monitoring
Forge > Server > Monitoring: tracks CPU, RAM, and disk usage automatically.
Health Check (Forge Scheduler)
Forge > Server > Scheduler — add a health check:
curl -sf https://hatched.live/api/v1/health > /dev/null || echo "API DOWN" | mail -s "Hatched Alert" admin@hatched.liveFrequency: Every 5 minutes
Step 12: Firewall
Forge > Server > Firewall:
| Port | Protocol | Source | Description |
|---|---|---|---|
| 22 | TCP | My IP | SSH |
| 80 | TCP | Any | HTTP (redirect) |
| 443 | TCP | Any | HTTPS |
Must remain closed externally:
- 3000 (API — only reachable via Nginx)
- 3001 (Dashboard — only reachable via Nginx)
- 5432 (PostgreSQL — localhost only)
- 6379 (Redis — localhost only)
Troubleshooting
Process won't start
# Check logs
sudo supervisorctl tail -f hatched-api stderr
# Try running manually
bash /home/forge/hatched.live/start-api.shPort conflict
# Which process is bound to which port
sudo lsof -i :3000
sudo lsof -i :3001Redis connection error
redis-cli ping # should return PONGPostgreSQL connection error
psql -U hatched -d hatched -h 127.0.0.1 -c "SELECT 1;"Migration error
cd /home/forge/hatched.live/apps/api
# Run individually
psql -U hatched -d hatched -h 127.0.0.1 -f src/database/migrations/001_initial_schema.sqlpnpm/node not found (in daemon)
Verify the PATH line in the wrapper scripts:
ls /home/forge/.nvm/versions/node/
# v20.x.x should be listedUpdates & Rollback
Normal Update
Forge > Site > Deploy Now (or rely on Quick Deploy)
Manual Rollback
ssh forge@SERVER_IP
cd /home/forge/hatched.live
git log --oneline -5 # find a previous commit
git checkout COMMIT_HASH # roll back to it
pnpm install --frozen-lockfile
pnpm --filter @hatched/shared build
pnpm --filter @hatched/api build
pnpm --filter @hatched/dashboard build
sudo supervisorctl restart allZero-Downtime (future)
For a more sophisticated setup:
- 2 servers + load balancer (Hetzner LB)
- Blue-green deploy
- Forge "Server Networks" feature
Monthly Cost Estimate
| Service | Plan | Cost |
|---|---|---|
| Hetzner CX32 | 4 vCPU, 8 GB RAM | ~€8 |
| Laravel Forge | Growth | $12 |
| Cloudflare R2 | Free tier (10 GB) | $0 |
| Domain | .live | ~$3 |
| fal.ai | Pay-per-use | ~$20–50 |
| Stripe | 2.9% + $0.30 per txn | Variable |
| Total | ~$45–75/mo |
Go-Live Checklist
- Hetzner server created
- Node.js 20 + pnpm installed
- PostgreSQL database + user created
- Redis installed and running
- Git repo connected in Forge
-
.envfully populated -
JWT_SECRET,EMBED_TOKEN_SECRETgenerated - Deploy script in place
- 3 daemons (API, Worker, Dashboard) defined
- Wrapper scripts created
- Nginx config written
- SSL certificate issued
- DNS records configured
- Migrations run
-
curl /api/v1/healthreturns OK - Dashboard login works
- Swagger docs open
- R2 bucket created
- Widget CDN deployed
- Firewall rules set
- fal.ai / Replicate API keys verified
- Stripe webhook endpoint configured