Manual contentsDeployment RecipesBrowse 103 chapters
Manual 10 min read

Deploy to Hetzner VPS

This guide covers deploying a Suprnova application to a VPS using Hetzner Cloud. The same principles apply to any single-box host - Linode, Vultr, AWS EC2, or a dedicated server you already own. Choose this path when you want full control of the box, predictable monthly cost, and the ability to colocate Postgres / Redis on the same machine.

Throughout the guide we use myapp as the project name and myapp.com as the domain - substitute your own.

Prerequisites

  • A VPS running Ubuntu 22.04 or Debian 12
  • SSH access to your server
  • A domain name pointed to your server's IP address
  • A Suprnova project - either a working source tree, or a Dockerfile generated with suprnova docker:init (see Docker)

Server Setup

1. Create a VPS

  1. Go to Hetzner Cloud Console
  2. Create a new project and add a server
  3. Choose Ubuntu 22.04 as the image
  4. Select your server size (CX11 is fine for small apps)
  5. Add your SSH key for secure access

2. Initial Server Configuration

SSH into your server and run initial setup:

# Update packages
apt update && apt upgrade -y

# Create a non-root user for your app
useradd -m -s /bin/bash app
mkdir -p /opt/myapp
chown app:app /opt/myapp

# Install required packages
apt install -y curl postgresql redis-server

3. Configure PostgreSQL

# Create database and user
sudo -u postgres psql << EOF
CREATE USER myapp WITH PASSWORD 'your_secure_password';
CREATE DATABASE myapp_production OWNER myapp;
GRANT ALL PRIVILEGES ON DATABASE myapp_production TO myapp;
EOF

Tip:

For production, consider using a managed database service like Hetzner's upcoming managed PostgreSQL, or services like Neon, Supabase, or AWS RDS for better reliability and backups.

Deploy Options

Choose one of the following deployment methods. Each one ends with a binary (or container) named app sitting at /opt/myapp/app, which the systemd unit below knows how to run.

Option A: Build Locally

Build on your machine and upload the binary. Replace myapp with your actual project name - cargo build names the binary after the [package].name in Cargo.toml:

# On your local machine - cross-compile for Linux (if on macOS)
cargo build --release --target x86_64-unknown-linux-gnu

# Or build with Docker for Linux (the Dockerfile renames the binary to `app`)
docker build -t myapp .
docker create --name temp myapp
docker cp temp:/app/app ./app-linux
docker rm temp

# Upload to the server, renaming to `app` on landing
scp target/x86_64-unknown-linux-gnu/release/myapp root@your-server:/opt/myapp/app
# or, if you went the Docker route:
scp ./app-linux root@your-server:/opt/myapp/app

Option B: Build on Server

Install Rust 1.91.1+ (Suprnova uses the 2024 edition) and build directly on the server:

# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env

# Clone, build, and place the binary at the standard path
cd /opt/myapp
git clone https://github.com/your-username/your-repo.git .
cargo build --release
cp target/release/myapp ./app   # rename so systemd's ExecStart=/opt/myapp/app finds it

Option C: Use Docker

Run your app in a Docker container - the scaffolded Dockerfile already names the runtime binary app (see Docker):

# Install Docker
curl -fsSL https://get.docker.com | sh

# Pull and run your image
docker run -d \
  --name myapp \
  --restart unless-stopped \
  -p 8765:8765 \
  --env-file /opt/myapp/.env.production \
  your-registry/myapp:latest

If you went with Docker, skip past the systemd section to Caddy Reverse Proxy - Docker handles process supervision.

Environment Configuration

First, generate a production APP_KEY on the server (or locally - the value is what matters). APP_KEY is a 32-byte AES-256 key used by suprnova::Crypt for session cookies and signed URLs. Suprnova fails closed at boot when APP_ENV is not local/dev/test and APP_KEY is unset - so this is non-optional in production:

suprnova key:generate --show
# -> APP_KEY=base64-url-safe-32-bytes

Then write the env file:

cat > /opt/myapp/.env.production << 'EOF'
APP_NAME="My App"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://myapp.com
APP_KEY=paste-the-generated-key-here

SERVER_HOST=127.0.0.1
SERVER_PORT=8765

# Database - bind to localhost when DB is on the same box
DATABASE_URL=postgres://myapp:your_secure_password@localhost:5432/myapp_production
DB_MAX_CONNECTIONS=10
DB_MIN_CONNECTIONS=1

# Session
SESSION_SECURE=true
SESSION_SAME_SITE=Lax

# Redis (optional - used by cache, queue, broadcasting drivers)
REDIS_URL=redis://127.0.0.1:6379

# Mail
MAIL_DRIVER=smtp
MAIL_HOST=your-smtp-host
MAIL_PORT=587
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=hello@myapp.com
MAIL_FROM_NAME="My App"
EOF

# Secure the file - only the app user should be able to read it
chmod 600 /opt/myapp/.env.production
chown app:app /opt/myapp/.env.production

See Configuration for the full env surface and how it becomes typed config.

systemd Services

A Suprnova binary supports multiple commands - ./app (serve, with auto-migrate), ./app schedule:work (scheduler daemon), ./app queue:work (queue worker), ./app workflow:work (workflow runner). Each long-running process gets its own systemd unit using the same binary and env file.

Web Server Service

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

[Unit]
Description=Suprnova Application
After=network.target postgresql.service redis.service
Requires=postgresql.service

[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/app
Restart=always
RestartSec=5

# Environment
EnvironmentFile=/opt/myapp/.env.production

# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/myapp

[Install]
WantedBy=multi-user.target

The default ExecStart=/opt/myapp/app runs serve with auto-migration. If you prefer migrations to be a separate deploy step, use ExecStart=/opt/myapp/app serve --no-migrate and run ./app migrate from your deploy script before flipping the binary.

Scheduler Service

If your app has tasks registered via Schedule::call(...) (see the Scheduling chapter), run exactly one scheduler process to avoid duplicate task execution. Create /etc/systemd/system/myapp-scheduler.service:

[Unit]
Description=Suprnova Scheduler
After=network.target myapp.service
Requires=myapp.service

[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/app schedule:work
Restart=always
RestartSec=5

# Environment
EnvironmentFile=/opt/myapp/.env.production

# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/myapp

[Install]
WantedBy=multi-user.target

Queue Worker (optional)

If you dispatch jobs to a queue, add /etc/systemd/system/myapp-queue.service:

[Unit]
Description=Suprnova Queue Worker
After=network.target myapp.service
Requires=myapp.service

[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/app queue:work
Restart=always
RestartSec=5

EnvironmentFile=/opt/myapp/.env.production

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/myapp

[Install]
WantedBy=multi-user.target

You can scale queue workers horizontally - multiple myapp-queue.service instances on the same or different boxes is safe.

Enable and Start Services

# Reload systemd after writing unit files
systemctl daemon-reload

# Enable services so they start on boot
systemctl enable myapp
systemctl enable myapp-scheduler
systemctl enable myapp-queue        # if you added the queue worker

# Start them now
systemctl start myapp
systemctl start myapp-scheduler
systemctl start myapp-queue

# Verify
systemctl status myapp
systemctl status myapp-scheduler
systemctl status myapp-queue

Caddy Reverse Proxy

Caddy automatically handles HTTPS certificates with Let's Encrypt.

Install Caddy

apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install caddy

Configure Caddy

Edit /etc/caddy/Caddyfile:

myapp.com {
    reverse_proxy localhost:8765

    # Enable compression
    encode gzip

    # Logging
    log {
        output file /var/log/caddy/myapp.log
    }
}

Replace myapp.com with your actual domain.

Start Caddy

systemctl enable caddy
systemctl start caddy

Caddy will automatically obtain and renew SSL certificates.

Health Checks

Suprnova ships a built-in /_suprnova/health endpoint that short-circuits before the middleware chain and never collides with your routes:

curl https://myapp.com/_suprnova/health
{
  "status": "ok",
  "timestamp": "2026-05-30T10:30:00Z"
}

Check Database Connectivity

Add ?db=true to also verify the database:

curl https://myapp.com/_suprnova/health?db=true

Healthy response (HTTP 200):

{
  "status": "ok",
  "timestamp": "2026-05-30T10:30:00Z",
  "database": "connected"
}

If the database check fails, the endpoint flips to HTTP 503 with "status": "degraded" and a "database_error" field - wire this into a livenessProbe / readinessProbe style health check so the load balancer can remove an unhealthy instance from rotation.

External Monitoring

Use the health endpoint with monitoring services:

  • UptimeRobot: Add HTTP monitor for https://myapp.com/_suprnova/health
  • Better Stack (formerly Better Uptime): Configure health check endpoint with the 503 trigger
  • Prometheus / Grafana: Scrape the JSON body for status + database fields

Deployment Script

Create a deployment script for atomic updates. Replace myapp with your project name (the [package].name in Cargo.toml) - that's what cargo build names the output binary:

#!/bin/bash
# deploy.sh - Run on your local machine

set -e

PROJECT="myapp"               # the Cargo package name
SERVER="root@your-server"
APP_PATH="/opt/myapp"
BIN="target/x86_64-unknown-linux-gnu/release/$PROJECT"

echo "Building application..."
cargo build --release --target x86_64-unknown-linux-gnu

echo "Uploading binary..."
scp "$BIN" "$SERVER:$APP_PATH/app.new"

echo "Deploying..."
ssh "$SERVER" << 'EOF'
    set -e
    cd /opt/myapp

    # Stop long-running services (ignore failures on first deploy)
    systemctl stop myapp-queue || true
    systemctl stop myapp-scheduler || true
    systemctl stop myapp

    # Atomic swap - rename is single-syscall on the same filesystem
    mv app.new app
    chmod +x app

    # Run migrations explicitly (the unit also auto-migrates, but doing
    # it here surfaces failures before we restart traffic)
    sudo -u app ./app migrate

    # Start services
    systemctl start myapp
    systemctl start myapp-scheduler || true
    systemctl start myapp-queue || true

    # Verify health (give the server a moment to bind)
    sleep 2
    curl -fsS http://localhost:8765/_suprnova/health?db=true > /dev/null || exit 1

    echo "Deployment complete!"
EOF

Make it executable:

chmod +x deploy.sh
./deploy.sh

Logs and Monitoring

View Logs

# Web server logs
journalctl -u myapp -f

# Scheduler logs
journalctl -u myapp-scheduler -f

# Caddy access logs
tail -f /var/log/caddy/myapp.log

Log Rotation

systemd's journald handles log rotation automatically. For long-term storage, consider:

  • Loki + Grafana: Self-hosted log aggregation
  • Papertrail: Cloud-based logging service
  • Logtail: Simple log management

Firewall Configuration

Secure your server with UFW:

# Allow SSH
ufw allow 22/tcp

# Allow HTTP/HTTPS (Caddy)
ufw allow 80/tcp
ufw allow 443/tcp

# Enable firewall
ufw enable

Warning:

Never expose port 8765 directly. Always use Caddy as a reverse proxy to handle SSL and security headers.

Scaling

A single Suprnova binary is very efficient - a small VPS handles a surprising amount of traffic before you need to scale out. When you do:

Vertical Scaling

Upgrade the VPS to a larger instance for more CPU/memory. The binary, env file, and systemd units come with you unchanged.

Horizontal Scaling

For multiple application instances:

  1. Set up a load balancer (Hetzner Load Balancer, HAProxy, or Caddy on a dedicated node)
  2. Move Postgres to a managed service or a dedicated node so app boxes are stateless
  3. Move sessions, cache, and broadcasting to Redis so any app instance can serve any request
  4. Deploy multiple app instances; each one safely runs its own auto-migrate on boot (the migration runner takes a lock so concurrent boots don't collide)
  5. Keep one scheduler (schedule:work) running across the whole fleet - queue workers are safe to run in parallel, the scheduler isn't

Why Suprnova diverges

Laravel typically runs PHP-FPM behind nginx, with cron triggering schedule:run once a minute and Horizon (or supervisord) managing queue workers. Suprnova collapses this into one binary with subcommands. ./app is a long-lived Tokio process - it doesn't need a process pool in front of it, doesn't need a separate cron, and stays warm across requests. systemd is the supervisor for both the web process and the workers, and Caddy is doing only what nginx couldn't avoid: terminating TLS and proxying.

Sizing

Pick a VPS based on workload, not on a marketing tier name. Hetzner's lineup changes periodically; the sizing logic doesn't:

Workload Rough fit
Small site, low traffic, SQLite or shared DB Smallest shared-vCPU instance (1 vCPU / 2 GB)
Moderate traffic with Postgres + Redis on the same box 2 vCPU / 4 GB
Heavier API + scheduler + queue workers + Postgres 2–4 vCPU / 8 GB
Production at scale Dedicated CPU instance, or split DB onto its own node

Check Hetzner's current pricing for the live catalogue. Suprnova's idle memory footprint is small (single-digit MB), so RAM is mostly database working set plus your domain code.

Troubleshooting

Service Won't Start

Check logs for errors:

journalctl -u myapp -n 50

Common issues:

  • Missing environment variables
  • Database connection failed
  • Port already in use

Caddy Certificate Errors

Ensure:

  • Domain DNS points to your server
  • Ports 80 and 443 are open
  • No other service is using port 80
caddy validate --config /etc/caddy/Caddyfile

Database Connection Issues

Test connection manually:

sudo -u app psql $DATABASE_URL -c "SELECT 1"

Health Check Failing

# Check if app is running
systemctl status myapp

# Test health endpoint directly
curl http://localhost:8765/_suprnova/health

# Check with database
curl http://localhost:8765/_suprnova/health?db=true

A 503 response with "status": "degraded" means the app is up but the database health check failed - inspect database_error in the body and check the DATABASE_URL, Postgres logs, and connection limits.

Next