Whiz.pub Deployment Guide

Prerequisites

  • A Linux server (Ubuntu 24.04 recommended, ARM64 or AMD64)
  • PostgreSQL database (managed or self-hosted)
  • Domain with DNS on Cloudflare (proxied)
  • Source repository access

Architecture

User --HTTPS--> Cloudflare (SSL termination)
                    |
                    v
          VM port 80 (HTTP) --> Caddy (reverse proxy)
                                  |
                                  v
                          localhost:8080 --> whiz-server (systemd)
                                              |
                                              v
                                          PostgreSQL (external)

Traffic flow:

  1. whiz.pub and *.whiz.pub DNS A records point to VM IP (Cloudflare proxied)
  2. Cloudflare terminates TLS and forwards HTTP to the origin (Flexible SSL)
  3. Caddy proxies port 80 to whiz-server on port 8080, setting X-Forwarded-Proto: https
  4. Server resolves tenant from Host header (subdomain or custom domain)
  5. API requests authenticated via Authorization: Bearer <key>
  6. HTML responses rendered server-side, cached 60s

1. Server Setup

Install dependencies

# Go 1.23 (adjust URL for amd64 if needed)
curl -sLO https://go.dev/dl/go1.23.9.linux-arm64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.9.linux-arm64.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/golang.sh
rm go1.23.9.linux-arm64.tar.gz

# PostgreSQL client (for migrations)
sudo apt-get install -y postgresql-client apache2-utils

# Caddy (reverse proxy)
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
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-get update && sudo apt-get install -y caddy

Prepare and configure

cd ~
# Copy or check out the private whiz source tree here
cd whiz

# Create .env from example
cp .env.example .env
# Edit .env with your DATABASE_URL and optional PLUNK keys

# Create production config
cat > whiz.yaml << 'EOF'
base_domain: whiz.pub
site_name: whiz.pub
session_ttl: 720h
max_title_length: 200
max_content_bytes: 102400
max_tag_length: 50
max_tags: 10
summary_length: 200
default_page_size: 20
max_page_size: 100
cache_max_age: 60
body_limit: 4194304
from_name: whiz.pub
from_email: [email protected]
EOF

2. Database Setup

Run migrations

source .env
psql "$DATABASE_URL" -f migrations/001_init.sql
psql "$DATABASE_URL" -f migrations/002_users.sql
psql "$DATABASE_URL" -f migrations/003_admin_email_settings.sql
psql "$DATABASE_URL" -f migrations/004_tenant_seo.sql
psql "$DATABASE_URL" -f migrations/005_tenant_theme.sql
psql "$DATABASE_URL" -f migrations/006_email_verification.sql

Seed first user and tenant

make seed

This creates a user ([email protected]) with a random password and a tenant with a random API key. Save the printed credentials -- the password cannot be recovered.

Alternatively, users can self-register via the web dashboard at https://whiz.pub/signup. The first user is auto-promoted to superadmin.

3. Build and Install

export PATH=$PATH:/usr/local/go/bin
go build -ldflags="-s -w" -o whiz-server ./cmd/server

# Install binary
sudo cp whiz-server /usr/local/bin/whiz-server

# Install config to system path
sudo mkdir -p /etc/whiz
sudo cp .env /etc/whiz/.env
sudo cp whiz.yaml /etc/whiz/whiz.yaml
sudo chmod 600 /etc/whiz/.env

4. Systemd Service

Create /etc/systemd/system/whiz.service:

[Unit]
Description=whiz.pub blogging platform
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
EnvironmentFile=/etc/whiz/.env
Environment=WHIZ_CONFIG=/etc/whiz/whiz.yaml
ExecStart=/usr/local/bin/whiz-server
WorkingDirectory=/etc/whiz
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=whiz

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/etc/whiz
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable whiz
sudo systemctl start whiz

# Verify
curl -sf http://localhost:8080/health
# Expected: {"status":"ok"}

5. Caddy Reverse Proxy

Create /etc/caddy/Caddyfile:

:80 {
	reverse_proxy localhost:8080 {
		header_up X-Forwarded-Proto https
	}
}

The header_up X-Forwarded-Proto https is critical -- Cloudflare Flexible SSL sends HTTP to the origin, but whiz-server needs to know the original protocol was HTTPS to generate correct URLs (e.g. https://user.whiz.pub instead of http://user.whiz.pub).

sudo systemctl restart caddy

6. DNS Configuration (Cloudflare)

Type Name Value Proxy
A whiz.pub (@) <VM_PUBLIC_IP> Proxied (orange cloud)
A *.whiz.pub (*) <VM_PUBLIC_IP> Proxied (orange cloud)

Important: Both the apex (@) and wildcard (*) records are needed. The wildcard only covers subdomains, not the bare domain.

Cloudflare SSL Settings

  • SSL/TLS mode: Flexible (Cloudflare terminates HTTPS, connects to origin via HTTP)
  • Always Use HTTPS: On (optional, redirects HTTP to HTTPS for end users)

With this setup:

  • Cloudflare handles all SSL certificates automatically (including wildcard)
  • No certificates are needed on the origin server
  • Caddy only serves HTTP on port 80

Firewall

If your cloud provider has a firewall (e.g. Oracle Cloud Security Lists, AWS Security Groups), open these ingress ports:

Protocol Port Source Description
TCP 80 0.0.0.0/0 HTTP (Cloudflare -> origin)
TCP 443 0.0.0.0/0 HTTPS (optional, for future Full SSL)
TCP 22 0.0.0.0/0 SSH

On Ubuntu with iptables (e.g. Oracle Cloud instances), you may also need:

sudo iptables -I INPUT 9 -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT 9 -p tcp --dport 443 -j ACCEPT
sudo apt-get install -y iptables-persistent
sudo netfilter-persistent save

7. CI/CD Auto-Deploy

Pushes to main can automatically deploy through your private CI system.

How it works

  1. CI runs tests (go vet, go test -race)
  2. On success, SSHs into the VM and runs ~/deploy.sh
  3. The deploy script: pulls latest code, builds binary, restarts service, health checks

Setup

A. Create the deploy script on the VM at ~/deploy.sh:

#!/bin/bash
set -euo pipefail
cd ~/whiz
echo "Pulling latest..."
git fetch origin main
git reset --hard origin/main
echo "Building..."
/usr/local/go/bin/go build -ldflags="-s -w" -o whiz-server ./cmd/server
echo "Deploying..."
sudo systemctl stop whiz
sudo cp whiz-server /usr/local/bin/whiz-server
sudo systemctl start whiz
echo "Waiting for server..."
for i in $(seq 1 10); do
  sleep 3
  if curl -sf --max-time 5 http://localhost:8080/health > /dev/null 2>&1; then
    echo "Health check passed on attempt $i"
    exit 0
  fi
  echo "Attempt $i: not ready yet..."
done
echo "ERROR: Health check failed after 30s"
exit 1
chmod +x ~/deploy.sh

B. Generate SSH deploy key (if one doesn't exist):

ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "deploy-key"
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys

Add the public key to your private source host for deploy access.

C. Add CI secrets/variables in a prod environment:

Type Name Value
Variable DEPLOY_HOST VM public IP
Variable DEPLOY_USER SSH username
Secret DEPLOY_KEY Contents of ~/.ssh/id_ed25519 (private key)

D. The CI workflow includes:

deploy:
  runs-on: ubuntu-latest
  needs: test
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  environment: prod
  steps:
    - name: Deploy to production
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ vars.DEPLOY_HOST }}
        username: ${{ vars.DEPLOY_USER }}
        key: ${{ secrets.DEPLOY_KEY }}
        script: bash ~/deploy.sh

Config preservation

The deploy script only rebuilds the binary (~/whiz/whiz-server -> /usr/local/bin/whiz-server) and restarts the service. It never touches /etc/whiz/.env or /etc/whiz/whiz.yaml. Production config changes must be made manually on the VM or via the admin panel at /admin/config.

8. Custom Domain Support

When a user adds a custom domain via the CLI or settings page:

  1. They set a DNS CNAME record: example.com -> proxy.whiz.pub
  2. They set a TXT record: _whiz-verify.example.com -> <tenant_id>
  3. They verify via CLI (whiz domain verify example.com) or the web dashboard

The server verifies the TXT record before marking the domain as verified. Superadmins can also force-verify domains from /admin/tenants without DNS checks.

Custom domain traffic is served by Whiz Edge through the DNS-only proxy.whiz.pub target. The edge issues Let's Encrypt certificates with Caddy on-demand TLS, serves rendered pages from R2, and falls back to the main Whiz origin on cache misses.

9. Admin Panel

The admin panel at /admin provides superadmin tools:

Feature Route Description
Dashboard GET /admin Platform stats, recent signups
Users GET /admin/users List users, suspend/unsuspend/delete/impersonate
Promote/Demote POST /admin/users/:id/promote Grant or revoke admin privileges
Tenants GET /admin/tenants List tenants, edit subdomains and custom domains
Edit Subdomain POST /admin/tenants/:id/subdomain Change a tenant's subdomain
Force Verify POST /admin/tenants/:id/verify Mark a custom domain verified (skip DNS check)
Config GET /admin/config View and edit all platform settings live

The first user is auto-promoted to superadmin on first startup.

10. CLI Installation

From install script

curl -sL https://whiz.pub/install | sh

From source

# From a checked-out whiz source tree
cd whiz
make build
sudo make install

Authenticate

whiz auth YOUR_API_KEY

11. Verify Deployment

# 1. Health check
curl https://whiz.pub/health
# Expected: {"status":"ok"}

# 2. Subdomain routing
curl https://admin.whiz.pub/
# Expected: Blog HTML page

# 3. Dashboard
# Open https://whiz.pub/login in a browser

# 4. Admin panel (superadmin only)
# Open https://whiz.pub/admin

# 5. Publish a test post via CLI
whiz auth <your-api-key>
whiz write hello.md
whiz publish hello.md

12. Operations

Useful commands

# Service management
sudo systemctl status whiz
sudo systemctl restart whiz
sudo journalctl -u whiz -f          # Tail logs

# Caddy management
sudo systemctl status caddy
sudo systemctl reload caddy          # Reload config without downtime

# Manual deploy
bash ~/deploy.sh

# Database backup
source ~/whiz/.env
pg_dump "$DATABASE_URL" > backup-$(date +%Y%m%d).sql

Scaling

The server is stateless. To scale:

  1. Run multiple instances behind a load balancer
  2. Add PostgreSQL read replicas if needed
  3. Cloudflare caches rendered HTML (Cache-Control: public, max-age=60 is already set)

13. Configuration Reference

Environment variables (.env)

Variable Required Default Description
DATABASE_URL Yes -- PostgreSQL connection string
PORT No 8080 HTTP listen port
WHIZ_CONFIG No ./whiz.yaml Path to app config file
PLUNK_API_URL No -- Plunk transactional email API URL
PLUNK_SECRET_KEY No -- Plunk API secret key
S3_ENDPOINT No -- S3/R2 endpoint for favicon and branding assets
S3_REGION No auto S3/R2 region
S3_BUCKET No -- Bucket for CDN assets
S3_ACCESS_KEY No -- S3/R2 access key
S3_SECRET_KEY No -- S3/R2 secret key
ASSETS_CDN_URL No -- Public CDN URL, e.g. https://cdn.whiz.pub

App config (whiz.yaml)

Key Default Description
base_domain localhost:8080 Platform domain (whiz.pub in production)
site_name whiz.pub Display name for branding
session_ttl 720h (30 days) Session duration
max_title_length 200 Max post title characters
max_content_bytes 102400 Max post content size (100KB)
max_tag_length 50 Max characters per tag
max_tags 10 Max tags per post
summary_length 200 Auto-summary truncation length
default_page_size 20 Default pagination size
max_page_size 100 Maximum pagination size
cache_max_age 60 Cache-Control max-age (seconds)
body_limit 4194304 HTTP body size limit (bytes)
from_name whiz.pub Sender name for emails
from_email [email protected] Sender email address

Settings are seeded from whiz.yaml on first run only. After that, they live in the database settings table and can be edited via the admin panel at /admin/config.

14. Troubleshooting

Problem Cause Fix
DATABASE_URL not set Missing env var Check /etc/whiz/.env
failed to connect to db Wrong connection string Check DATABASE_URL, network, SSL mode
URLs show http:// instead of https:// Caddy not setting X-Forwarded-Proto Add header_up X-Forwarded-Proto https to Caddyfile
Port 80 unreachable Cloud firewall blocking Open port 80 in cloud provider's security group/list + iptables
Wildcard DNS not working Missing * A record Add *.whiz.pub A record in Cloudflare
Apex domain not resolving Missing @ A record Add whiz.pub (@) A record in Cloudflare
Deploy script fails with exit 7 curl health check too early Increase sleep/retry in ~/deploy.sh
Custom domain not serving Domain not verified or DNS not propagated Run verify from settings page, or admin force-verify
Health check failing DB connection lost Check PostgreSQL connectivity and credentials
Favicon upload says request too large body_limit too low Set body_limit to at least 4194304 and restart
Favicon upload fails Missing S3/R2 env vars Check S3_* and ASSETS_CDN_URL in /etc/whiz/.env
User cannot publish after signup Email not verified Use dashboard banner, whiz verify CODE, API, or MCP verify tool
go build fails on VM Go not installed Install Go: see Server Setup section

15. Security Notes

  • API keys are stored in plaintext in PostgreSQL. Consider hashing them in a future version.
  • The .env file at /etc/whiz/.env is set to 0600 permissions (root-only).
  • All HTML output is escaped to prevent XSS attacks (except markdown body which is trusted).
  • Internal error messages are never exposed to clients; they are logged server-side.
  • Domain verification requires a TXT DNS record matching the tenant ID.
  • Input validation enforces limits on slug length, content size, tag count, and status values.
  • The systemd service runs with NoNewPrivileges, ProtectSystem=strict, and PrivateTmp.