Installation & Setup

Network Deployment

Supported HTTP protocols

Runique natively supports HTTP/1.1 and HTTP/2 via Axum/Hyper.

HTTP/2 and the Host header

HTTP/2 does not send a Host header — it uses the :authority pseudo-header instead. Runique handles this automatically: the allowed_hosts middleware and the HTTPS redirect middleware first read the Host header, then fall back to request.uri().authority() when absent.

This behavior covers HTTP/1.1, HTTP/2, and reverse proxies (nginx, Caddy, Cloudflare).

HTTP/3

HTTP/3 runs over QUIC (UDP) and is not natively supported by Axum/Hyper at this time. To benefit from it, two options:

OptionDescription
Cloudflare (recommended)Terminates HTTP/3 on Cloudflare's side, proxies HTTP/2 to Runique. Zero server-side configuration.
Reverse proxy (Caddy, nginx)Some reverse proxies support HTTP/3 and proxy HTTP/1.1 or HTTP/2 to Runique.

Runique directly on the internet = HTTP/2 maximum.

ACME / Automatic TLS

The acme feature allows Runique to manage its own Let's Encrypt certificates without a reverse proxy.

# Cargo.toml
runique = { features = ["acme"] }
# .env
ACME_ENABLED=true
ACME_DOMAIN=mydomain.com
ACME_EMAIL=admin@mydomain.com
ACME_CERTS_DIR=/absolute/path/to/certs   # default: ./certs

ACME_CERTS_DIR should be an absolute path in production. A relative path depends on the systemd WorkingDirectory — if not set correctly, the certificate is not found and the server crashes on every restart.

If ACME_ENABLED=true but the acme feature is not compiled in, Runique prints a warning at startup.

Limitation — one site per machine

ACME requires exclusive use of port 80. If multiple applications run on the same server, only one can use ACME — the others cannot obtain their certificates simultaneously.

In a multi-site setup, use a reverse proxy (nginx, Caddy) that manages Let's Encrypt itself, and run each Runique instance on a separate internal port with ACME_ENABLED=false.

Required ports

PortUsage
80Let's Encrypt HTTP-01 challenge + HTTPS redirect
443HTTPS (TLS)

To listen on these ports without root, use CAP_NET_BIND_SERVICE:

# /etc/systemd/system/runique.service
[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

Behind a reverse proxy

If a reverse proxy (nginx, Caddy, Cloudflare) handles TLS, Runique runs over HTTP on an internal port (e.g. 3000) and does not require the acme feature.

ACME_ENABLED=false
PORT=3000
IP_SERVER=127.0.0.1

For HTTPS redirection, let the proxy handle it and disable ENFORCE_HTTPS on Runique's side to avoid a double redirect.

TLS hardening

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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
server_tokens off;

Security headers on media files

Runique injects its security headers (CSP, HSTS, X-Content-Type-Options, etc.) into every response it generates. But files served directly by Nginx via alias (a location /media/ block) bypass Runique entirely — Nginx must add the security headers itself.

location /media/ {
    alias /var/www/myproject/media/;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
}

The always flag is required: without it, Nginx only sends these headers on 2xx/3xx responses, not on 4xx/5xx errors.

Full example (multi-site)

server {
    listen 80;
    server_name mydomain.com www.mydomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name mydomain.com www.mydomain.com;

    ssl_certificate     /etc/letsencrypt/live/mydomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mydomain.com/privkey.pem;

    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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    server_tokens off;

    client_max_body_size 10M;

    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }

    location /media/ {
        alias /var/www/myproject/media/;
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
        add_header X-Content-Type-Options "nosniff" always;
    }
}