HatchedDocs
Platform

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

  1. Forge > Servers > Create Server
  2. Provider: Hetzner Cloud (API key required)
  3. Recommended plans:
EnvironmentHetzner SizeCPURAMDiskPrice
StagingCX222 vCPU4 GB40 GB~€4/mo
ProductionCX324 vCPU8 GB80 GB~€8/mo
  1. Region: Nuremberg (eu-central) or Helsinki (eu-north)
  2. Server Type: App Server (Nginx included)
  3. Database: PostgreSQL 16 (Forge installs automatically)
  4. 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_IP

2.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.x

2.2 Install pnpm

corepack enable
corepack prepare pnpm@9.15.4 --activate
pnpm -v  # 9.15.4

2.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:

  1. Add Database: hatched
  2. Add Database User: hatched (set a password and save it)
  3. 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/settings

Generate 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

FieldValue
Command/home/forge/.nvm/versions/node/v20.*/bin/node /home/forge/hatched.live/apps/api/dist/main.js
Userforge
Directory/home/forge/hatched.live/apps/api
Processes1
Start Seconds5

Note: Forge runs daemons under supervisord; crashes are restarted automatically.

6.2 Worker Process

FieldValue
Command/home/forge/.nvm/versions/node/v20.*/bin/node /home/forge/hatched.live/apps/api/dist/worker.js
Userforge
Directory/home/forge/hatched.live/apps/api
Processes1
Start Seconds5

6.3 Dashboard (Next.js)

FieldValue
Command/home/forge/.nvm/versions/node/v20.*/bin/node /home/forge/hatched.live/apps/dashboard/node_modules/.bin/next start --port 3001
Userforge
Directory/home/forge/hatched.live/apps/dashboard
Processes1
Start Seconds5

Important: supervisord does not source the .env file 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-*.sh

Then update the daemon Commands in Forge:

DaemonCommand
hatched-apibash /home/forge/hatched.live/start-api.sh
hatched-workerbash /home/forge/hatched.live/start-worker.sh
hatched-dashboardbash /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

  1. Cloudflare Dashboard > R2 > Create Bucket: hatched-widgets
  2. 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:cdn

Or 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=xxxxxxxxxxxx

Step 9: DNS Records

Add these records at your DNS provider (Cloudflare, etc.):

TypeNameValueProxy
Ahatched.liveHETZNER_IPOptional
AwwwHETZNER_IPOptional
CNAMEcdnR2 custom-domain valueProxied (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:migrate

10.2 Seed Data (optional)

cd /home/forge/hatched.live/apps/api
pnpm db:seed

10.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 status

Expected 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:00

Step 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.log

Forge 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.live

Frequency: Every 5 minutes


Step 12: Firewall

Forge > Server > Firewall:

PortProtocolSourceDescription
22TCPMy IPSSH
80TCPAnyHTTP (redirect)
443TCPAnyHTTPS

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.sh

Port conflict

# Which process is bound to which port
sudo lsof -i :3000
sudo lsof -i :3001

Redis connection error

redis-cli ping  # should return PONG

PostgreSQL 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.sql

pnpm/node not found (in daemon)

Verify the PATH line in the wrapper scripts:

ls /home/forge/.nvm/versions/node/
# v20.x.x should be listed

Updates & 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 all

Zero-Downtime (future)

For a more sophisticated setup:

  • 2 servers + load balancer (Hetzner LB)
  • Blue-green deploy
  • Forge "Server Networks" feature

Monthly Cost Estimate

ServicePlanCost
Hetzner CX324 vCPU, 8 GB RAM~€8
Laravel ForgeGrowth$12
Cloudflare R2Free tier (10 GB)$0
Domain.live~$3
fal.aiPay-per-use~$20–50
Stripe2.9% + $0.30 per txnVariable
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
  • .env fully populated
  • JWT_SECRET, EMBED_TOKEN_SECRET generated
  • 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/health returns 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