Guides and recipes

Complete host files for the setups people ask about most. Copy one into /etc/arka/hosts/, change the names and paths, and save — it is live a moment later.

Every recipe assumes the [tls] lines from Your first sites for automatic HTTPS; they are repeated only where something differs.

WordPress#

# /etc/arka/hosts/blog.example.com.toml
server_names = ["blog.example.com"]

[tls]
acme = true
redirect_http = true
hsts = true

[limits]
max_body_size = "64MB"              # media uploads; match PHP's upload_max_filesize

[waf]                               # the OWASP Core Rule Set, learning first
level = "standard"
learn_until = "2026-11-05T09:00:00Z"  # logs only until then (UTC); the panel sets a day ahead

[[waf.exceptions]]                  # the editors post HTML
rules = ["941100-941999"]
paths = ["/wp-admin/post.php", "/wp-json/wp/v2/"]
args = ["content"]

[[routes]]                          # what bots hammer: slowed down
path = "^/(wp-login|xmlrpc)\\.php$"
match = "regex"
rate_limit = { requests_per_second = 0.5, burst = 10 }
handler = { type = "fastcgi", address = "unix:/run/php/php8.3-fpm.sock", root = "/var/www/wordpress", max_conns = 5 }

[[routes]]
path = "/"
[routes.handler]
type = "fastcgi"
address = "unix:/run/php/php8.3-fpm.sock"
root = "/var/www/wordpress"
front_controller = "/index.php"
max_conns = 5                       # php-fpm's pm.max_children
queue = { size = 200, timeout = "30s" }
static_cache_control = "public, max-age=2592000"   # themes version CSS and JS (?ver=)
deny_scripts = ["/wp-content/uploads/"]            # an uploaded .php never runs
cache = { default_ttl = "10m", stale_while_revalidate = "1m", stale_if_error = "1h" }
  • Pretty permalinks work as they are: any path that is not a file goes to index.php. There is no .htaccess — Arka does not read those files, and does not need to.
  • The page cache answers visitors from memory and refreshes pages in the background; signed-in users, comment authors and WooCommerce carts always get fresh pages (their cookies skip the cache), and if PHP or the database fails, the last good page stands in for up to an hour. The X-Cache header shows what happened.
  • New content appears within default_ttl (10 minutes here). To show it at once, send PURGE for the page from the server itself — curl -X PURGE --resolve blog.example.com:443:127.0.0.1 https://blog.example.com/hello-world/ — or PURGE /* for everything, or use the Purge button on the host's page. Cache-purging plugins written for Varnish send the same PURGE request; point them at this server.
  • Uploads are served as plain files, and a .php file someone smuggles into uploads/ is refused, never run.
  • php-fpm's workers are never overrun: max_conns keeps Arka to as many connections as PHP has workers, and visitors beyond that wait in the queue. Raise both together when you give php-fpm more workers.
  • While the firewall learns, look at the panel's Firewall page: Allow here on anything legitimate it would have refused adds the exception for you. Then it blocks by itself. Payment and shop webhooks, uploads and the block editor's REST calls already pass at standard.
  • HTTPS inside WordPress: Arka tells PHP the request came over HTTPS ($_SERVER['HTTPS'] = 'on'), so WordPress builds https:// links without plugins.

Laravel#

server_names = ["app.example.com"]

[[routes]]
path = "/"
[routes.handler]
type = "fastcgi"
address = "unix:/run/php/php8.3-fpm.sock"
root = "/var/www/app/public"        # Laravel's public folder, never the project root
front_controller = "/index.php"
follow_symlinks = true              # `php artisan storage:link` makes public/storage a link
max_conns = 5                       # php-fpm's pm.max_children
queue = { size = 200, timeout = "30s" }
params = { APP_ENV = "production" }

The same pattern fits Symfony (root = ".../public"), Drupal and CodeIgniter. Add follow_symlinks = true only when the application links out of root on purpose; without it, a link pointing outside the site answers 404.

Django, Flask and other Python apps#

Run the application with gunicorn (or uvicorn) on a local port, and let Arka serve the static files, protect the workers from slow visitors and queue requests when every worker is busy:

server_names = ["shop.example.com"]

[[routes]]                          # python manage.py collectstatic → STATIC_ROOT
path = "/static/"
handler = { type = "static", root = "/srv/shop/staticfiles", strip_prefix = true, cache_control = "public, max-age=3600" }

[[routes]]
path = "/media/"
handler = { type = "static", root = "/srv/shop/media", strip_prefix = true }

[[routes]]
path = "/"
[routes.handler]
type = "proxy"
upstreams = ["127.0.0.1:8000 max_conns=4"]   # = gunicorn --workers 4
queue = { size = 200, timeout = "30s" }      # wait in line instead of piling onto the workers
preserve_host = true
buffering = {}                                # slow phones never hold a worker
health_check = { path = "/healthz", interval = "5s" }

In Django's settings:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True            # safe behind Arka: visitors cannot forge it
CSRF_TRUSTED_ORIGINS = ["https://shop.example.com"]
ALLOWED_HOSTS = ["shop.example.com"]

Why max_conns and queue: gunicorn's sync workers handle one request each. Without a limit, a burst sends more requests than there are workers and they queue invisibly inside gunicorn; with max_conns, Arka holds them in a fair first-come, first-served line, answers 503 with Retry-After when the line is full, and shows the queue on the host's page.

Private downloads (django-sendfile, Rails send_file, Laravel): the application checks permissions and answers with an X-Accel-Redirect header; Arka then serves the file itself. See X-Accel-Redirect.

Node.js, Next.js, Nuxt, Go, Rust#

server_names = ["app.example.com"]

[[routes]]
path = "/"
handler = { type = "proxy", upstreams = ["127.0.0.1:3000"] }

WebSockets pass through without extra settings (idle ones close after an hour; change it with websocket_idle_timeout). For long-lived streams such as server-sent events, nothing is buffered — text/event-stream answers always stream.

Run two copies of the application and list both, and a deployment never shows an error page: restart one, then the other, while Arka sends visitors to whichever is up.

handler = { type = "proxy", upstreams = ["127.0.0.1:3000", "127.0.0.1:3001"], retries = 2 }

A single-page app and its API on one domain#

server_names = ["example.com"]

[[routes]]
path = "/api/"
handler = { type = "proxy", upstreams = ["127.0.0.1:8080"], strip_prefix = true }   # /api/users → /users

[[routes]]
path = "/"
handler = { type = "static", root = "/var/www/app/dist", fallback = "index.html" }

The longest matching prefix wins, so /api/… goes to the application and everything else to the app's files. Same origin, so no CORS settings are needed.

Load balancing#

[[routes]]
path = "/"
[routes.handler]
type = "proxy"
upstreams = ["10.0.0.11:8080 weight=2", "10.0.0.12:8080", "10.0.0.13:8080 backup"]
strategy = "least_time"
health_check = { path = "/healthz", interval = "5s", expect_body = "ok" }
outlier = {}                     # eject upstreams that answer 5xx or time out
slow_start = "30s"               # ease a recovered upstream back in
retry_on = ["error", "timeout", "502", "503"]
Strategy Sends each request to Use it for
round_robin (default) the next upstream in turn, by weight identical servers
least_conn the one with the fewest requests in flight requests of very different lengths
least_time the one answering fastest lately, weighed by load servers of different speeds; one that starts to struggle loses traffic at once
p2c the less loaded of two picked at random many upstreams, many Arka servers
ip_hash the same server for the same visitor simple stickiness
hash a server chosen by any key, e.g. hash_key = "{cookie.cart}" caches, sharding
random any tests

Taking a server out for maintenance needs no file edit: on the host's page in the panel, set the upstream to draining — it takes no new visitors while its requests in flight finish — then disabled, then enabled again when you are done.

A new version for a few visitors first — a canary release:

canary = { upstreams = ["10.0.0.21:8080"], percent = 5, header = "X-Canary" }

Five percent of new visitors are sent to the new version and stay with it; testers can force it with the header X-Canary: always. Raise percent as confidence grows. To try a new version with real traffic but without visitors seeing its answers, use mirror instead.

Caching application responses#

A cache in front of an application turns a slow page into a fast one and keeps the site up when the application is down:

[[routes]]
path = "/"
[routes.handler]
type = "proxy"
upstreams = ["127.0.0.1:8000"]
[routes.handler.cache]
max_size = "256MB"
default_ttl = "10s"                    # "micro-caching": even 10 seconds absorbs a traffic spike
stale_while_revalidate = "1m"          # serve the old copy while fetching a new one
stale_if_error = "1h"                  # application down? keep serving what we have
bypass = [{ source = "cookie", name = "sessionid", op = "exists" }]   # signed-in users skip it
disk = { path = "/var/cache/arka/shop", max_size = "2GB" }          # survives restarts

The disk tier needs a folder the service may write to. Arka's sandbox allows only its own folders, so create one and allow it:

sudo mkdir -p /var/cache/arka && sudo chown arka:arka /var/cache/arka
sudo systemctl edit arka        # add the two lines below, save
#   [Service]
#   ReadWritePaths=/var/cache/arka
sudo systemctl restart arka

(A cache folder inside /var/lib/arka is refused on purpose: no site may point into Arka's own state, where its keys and accounts live.)

When a thousand visitors ask for the same expired page at once, one request goes to the application and the others wait for its answer (lock = true, the default). Responses carry X-Cache: HIT, MISS or STALE. Arka follows the application's Cache-Control: private, no-store and responses that set cookies are never stored. Purge a host's cache from its page in the panel.

Redirects and rewrites#

# An old address → the new one, permanently
[[routes]]
path = "/old-blog/"
handler = { type = "redirect", to = "https://blog.example.com{uri}", code = 301 }

# Regular expressions, with captures
[[routes]]
path = "^/product/(\\d+)$"
match = "regex"
handler = { type = "redirect", to = "/shop/item/{1}", code = 301 }

# Rewrite internally — the visitor's address bar does not change
[[rewrites]]
from = "^/p/(\\d+)$"
to = "/products/{1}"

Templates can use {scheme}, {host}, {uri}, {path}, {query}, {client_ip}, {header.Name}, {cookie.name}, {arg.name}, {1}…{9} and more — the full list is in the reference.

Many sites on one server#

Give every site its own file — /etc/arka/hosts/<name>.toml — and they never interfere: they share ports 80 and 443, and each gets its own certificate, chosen by the name the browser asks for. A host marked default = true answers requests for names no other host claims (for example visits by IP address). Settings several sites should share — security headers, a firewall setup — go into a snippet:

# /etc/arka/snippets/standard.toml
[headers]
security = true

[waf]
level = "standard"
mode = "block"
# in each host
include = ["snippets/standard.toml"]

Wildcard certificates#

A certificate for *.example.com needs the dns-01 challenge: Arka creates a DNS record to prove you own the domain. Point it at a small script that talks to your DNS provider's API:

# /etc/arka/arka.toml
[acme]
email = "you@example.com"
challenge = "dns-01"
dns_hook = "/etc/arka/dns-hook.sh"     # run as: dns-hook.sh add|remove <record> <value>
# the host
server_names = ["*.example.com", "example.com"]
[tls]
acme = true

The hook is called with add _acme-challenge.example.com <value> and later remove …. Ready-made command-line clients exist for Cloudflare, Route 53, DigitalOcean, Hetzner and most other providers.

Behind Cloudflare or a load balancer#

Visitors then reach Arka through the proxy's addresses. Tell Arka which addresses to trust, so logs, rate limits, bans and IP rules see the real visitor:

[real_ip]
from = ["173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22",
        "141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20",
        "197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
        "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22"]       # Cloudflare's IPv4 ranges
header = "CF-Connecting-IP"

(Cloudflare publishes its current list at cloudflare.com/ips — add the IPv6 ranges too.) Only these addresses are believed; anyone else sending CF-Connecting-IP is ignored. For load balancers that use the PROXY protocol (AWS NLB, HAProxy), set proxy_protocol under [server] instead.

A password-protected area#

sudo -u arka arka passwd alice          # prints alice:$2b$… — paste it below
[[routes]]
path = "/staff/"
auth = { type = "basic", realm = "Staff", users = ["alice:$2b$…"] }
handler = { type = "static", root = "/var/www/staff", strip_prefix = true }

Passwords are stored only as bcrypt hashes. Ten wrong attempts lock an address out for a while.

Sign in with Google (or Microsoft, Okta, Keycloak) for an internal tool#

[auth]
type = "oidc"
issuer = "https://accounts.google.com"
client_id = "1234.apps.googleusercontent.com"
client_secret = "${GOOGLE_CLIENT_SECRET}"
allowed_users = ["@example.com"]                # everyone with a company address
claims_to_headers = { email = "X-User-Email" }  # the app learns who it is

Register https://<your site>/.arka/oidc/callback as the redirect address with the provider. The application behind it needs no login code at all. Other sign-in methods — LDAP and Active Directory, JWT, Authelia/Authentik — are in the security guide.

A file share (WebDAV)#

A folder that Windows Explorer, macOS Finder and Linux file managers can open as a network drive:

[[routes]]
path = "/files/"
handler = { type = "webdav", root = "/srv/share", strip_prefix = true }
auth = { type = "basic", users = ["alice:$2b$…"] }

In Finder: Go → Connect to Server → https://files.example.com/files/. In Windows: Map network drive → the same address.

The arka user must be able to write to the folder, and the service's sandbox must allow it:

sudo chown -R arka:arka /srv/share
sudo systemctl edit arka        # add:  [Service]  ReadWritePaths=/srv/share
sudo systemctl restart arka

A maintenance page#

[[routes]]
path = "/"
handler = { type = "respond", status = 503, body = "Back in a few minutes.", content_type = "text/plain; charset=utf-8" }

Put it first in the file while you work, and remove it afterwards — or, for an application, simply disable its upstreams on the host's page; visitors then get 503 with Retry-After and your custom 503 page, if you set one in [error_pages].

gRPC#

[[routes]]
path = "/"
handler = { type = "proxy", upstreams = ["127.0.0.1:50051"], http2 = true }

http2 = true makes Arka speak HTTP/2 to the upstream, which gRPC needs; trailers pass through.

Databases and other TCP services#

Arka can also pass plain TCP and UDP through — a database, a mail server, a game server — with load balancing and per-address limits. These are streams, one file each in /etc/arka/streams/:

# /etc/arka/streams/postgres.toml
listen = ["10.0.0.1:5432"]                  # the private address only
upstreams = ["10.0.0.5:5432", "10.0.0.6:5432 backup"]
max_connections_per_ip = 20
access = { allow = ["10.0.0.0/8"] }

Streams can also route TLS connections by name without decrypting them — see Streams in the reference.