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:
whiz.puband*.whiz.pubDNS A records point to VM IP (Cloudflare proxied)- Cloudflare terminates TLS and forwards HTTP to the origin (Flexible SSL)
- Caddy proxies port 80 to whiz-server on port 8080, setting
X-Forwarded-Proto: https - Server resolves tenant from
Hostheader (subdomain or custom domain) - API requests authenticated via
Authorization: Bearer <key> - 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
- CI runs tests (
go vet,go test -race) - On success, SSHs into the VM and runs
~/deploy.sh - 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:
- They set a DNS CNAME record:
example.com->proxy.whiz.pub - They set a TXT record:
_whiz-verify.example.com-><tenant_id> - 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:
- Run multiple instances behind a load balancer
- Add PostgreSQL read replicas if needed
- Cloudflare caches rendered HTML (
Cache-Control: public, max-age=60is 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
.envfile at/etc/whiz/.envis set to0600permissions (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, andPrivateTmp.