Configuration reference
This is the complete reference; the copy for your installed version is in
/usr/share/doc/arka/configuration.md. In the package,arka.tomlis/etc/arka/arka.toml, host files live in/etc/arka/hosts/, and relative paths start from/etc/arka.
Arka reads four kinds of files, all plain TOML:
arka.toml— how the process runs: workers, timeouts, the control panel, certificates from ACME, tracing, clustering. It never describes a website.hosts/<id>.toml— one file per site. Each file is complete on its own: nothing is inherited, and a mistake in one file never affects another.streams/<id>.toml— one file per TCP or UDP service (databases, mail, MQTT, game servers, TLS passthrough).snippets/*.toml— pieces that hosts share on purpose, withinclude.
Relative paths anywhere (document roots, certificates, password files, scripts, logs) are resolved against the directory that contains arka.toml. Durations are written like "30s", "500ms", "5m", "1h", "7d"; sizes like "512KB", "16MB", "1GiB". String values may use environment variables: "${DB_PASSWORD}".
Check everything without starting the server with arka check. Changes to host, stream and snippet files are applied automatically (with watch = true), through the control panel, or with arka reload. Settings in arka.toml are read at startup; arka upgrade re-reads them without refusing a single connection.
Every key below can also be edited in the control panel. Saving from a form rewrites only what changed — comments, layout, ${…} references and untouched lines are kept, and new keys are written only when they differ from their defaults — so editing by hand and in the panel mix freely.
Contents
arka.toml:[server]·[admin]and panel accounts ·[acme]·[geoip]·[tracing]·[cluster]·[update]·[bans]- Host files: names and listeners ·
[tls]·[[routes]]· route conditions - Handlers: static · proxy · fastcgi · webdav · script · redirect · respond
- Security:
[auth]·[waf]· bans ·[access]·[geo]·[challenge]·[rate_limit]·[connections]· signed links ·[limits]·[real_ip]·[cors] - Content:
[[rewrites]]·[[variables]]·[headers]·[compression]·[bandwidth]·body_replace·[error_pages]·[log] - Streams · Snippets · Environment variables · Scripts
- Templates · Request pipeline · Command line · Metrics
arka.toml#
[server]#
| Key | Default | Meaning |
|---|---|---|
workers |
"auto" |
Worker threads. "auto" runs one per CPU core, each with its own event loop and SO_REUSEPORT sockets. |
cpu_affinity |
false |
Pin each worker to its own CPU, and have the kernel hand each worker's listening sockets the connections that arrive on that CPU (SO_INCOMING_CPU), so a request is handled where its packets landed. For a machine given to Arka with a multi-queue network card; leave it off when other busy programs share the CPUs. |
hosts_dir |
"hosts" |
Directory of host files (<id>.toml). |
streams_dir |
"streams" |
Directory of stream files. |
snippets_dir |
"snippets" |
Directory of snippets hosts can include. |
logs_dir |
"logs" |
Access logs of hosts without log.path, and the panel's audit log. |
state_dir |
"state" |
What Arka manages itself: the last good version of every host, file history, generated and ACME certificates, panel accounts, the sign-in key. |
pid_file |
"arka.pid" |
Used by arka reload / stop / upgrade; also prevents two servers from sharing ports by accident. "" disables it. |
watch |
true |
Apply host, stream and snippet file changes automatically. |
log_level |
"info" |
Process log filter, e.g. "info,arka_server=debug". The ARKA_LOG variable overrides it. |
http2 |
true |
Serve HTTP/2 (ALPN over TLS, prior knowledge in clear text). |
http2_max_streams |
256 |
Requests one HTTP/2 connection may have in flight at once. |
http2_max_resets |
200 |
A client that cancels more requests than this on one HTTP/2 connection within 10 seconds is cut off — the "rapid reset" attack — and earns ban points. 0: no limit. |
http2_stream_window, http2_connection_window |
"2MB", "8MB" |
How much of a request body an HTTP/2 client may send ahead, per request and per connection. Larger windows upload faster over long distances; smaller ones bound the memory uploads to a slow app can hold — up to the connection window for every open connection. |
header_read_timeout |
"30s" |
Time to receive request headers. Also closes idle keep-alive connections and cuts off slowloris attacks. |
write_timeout |
"60s" |
How long a response may wait on a client that stopped reading (nginx send_timeout). A connection whose writes get no byte out for this long is closed, whatever it is doing — otherwise such a client holds it, and behind a proxy an upstream connection, for ever. Slow readers that take a few bytes now and then are never cut. "0s" turns it off. |
buffer_disk_max |
"2GB" |
Disk all buffered bodies may take at once: proxy buffering and uploads held for the virus scanner, in temporary files in state_dir. Past it — or with less than 1 GB free on that disk — uploads pass straight through and answers wait for their clients, so slow clients on large answers cannot fill the disk that holds certificates and state. |
max_header_size |
"64KB" |
Largest request line plus headers — over HTTP/1, HTTP/2 and HTTP/3 alike (an HTTP/3 header block, or any frame but a body's, declaring more is refused before it is read). |
tls_handshake_timeout |
"10s" |
Time for a client to finish the TLS handshake. |
tls_session_cache |
16384 |
TLS sessions kept for resumption, shared by every HTTPS host (0 turns resumption off). Like nginx ssl_session_cache, but a count of sessions. |
tls_session_timeout |
"1d" |
How long a resumed TLS session stays valid (nginx ssl_session_timeout). Enforced for cached sessions and advertised as the ticket lifetime. |
tls_session_tickets |
true |
Issue stateless session tickets (nginx ssl_session_tickets). Off still allows cache-based resumption. |
shutdown_timeout |
"10s" |
How long in-flight requests may run after arka stop. |
backlog |
4096 |
Pending-connection queue per listening socket. |
max_connections |
0 |
Simultaneous connections per worker (0 = unlimited). |
max_connections_per_ip |
0 |
Simultaneous connections from one address (0 = unlimited). A hard limit: more are refused. Use it with care — thousands of mobile users share a carrier's address, a whole office shares one NAT, and a site behind a CDN gets all its traffic from a few addresses. shed_idle_connections protects without refusing anyone. |
shed_idle_connections |
true |
Once the server holds connection_pressure connections, close the idle kept-open connections (quiet for 2 s, no request in flight) of the clients holding the most — at least 1/128 of all open connections, and at least 32. Nobody is refused; requests being answered and WebSockets are never touched; a browser or CDN whose idle connection closes opens a new one when it next asks. Counted in arka_connections_shed_total. |
connection_pressure |
0 |
Open connections at which idle ones start being shed. 0 works it out: as many as a quarter of memory holds at about 32 KB each (the container's limit counts), within three quarters of the descriptor limit — 16,384 on a 2 GB server. |
http3_validate_after |
64 |
HTTP/3 handshakes in progress from which new clients must first prove their address (a QUIC Retry: one round trip more), so packets from forged addresses cannot make the server sign certificates for nothing. 0: always. HTTP/3 connections otherwise follow the TCP limits: they count towards max_connections_per_ip and towards who holds the most when idle connections are shed, all together stay within max_connections × workers, and under connection pressure the heaviest clients get no new one. A refused HTTP/3 connection costs a browser nothing — it carries on over HTTP/2. |
http3_threads |
0 |
Threads serving HTTP/3. 0: half the workers (at least 2). More raise HTTP/3's peak throughput at a higher CPU cost per request and a longer tail — as many as the workers gave 19% more requests a second here, for 50% more CPU each and four times the p99 latency. Arka also asks the kernel for 8 MB UDP buffers on its QUIC sockets, and warns at start when net.core.rmem_max allows less (the .deb raises it on install). |
proxy_protocol |
[] |
Addresses whose connections start with a PROXY protocol header (v1 or v2, from HAProxy or a cloud load balancer), e.g. [":443"]. The client address is taken from it. |
web_roots |
[] (["/var/www", "/srv"] in the .deb's arka.toml) |
The folders sites may be served from: static, PHP and WebDAV roots and error pages must lie inside one of them. Empty: anywhere but the system's own folders (see what hosts cannot reach). Set in arka.toml only. |
allow_sockets |
[] |
Sockets that control the machine — Docker's, systemd's… — a site may still proxy to, e.g. ["/var/run/docker.sock"]. Set in arka.toml only. |
proxy_protocol_from |
[] |
Who may send those headers: the load balancers' addresses. Other peers are refused on those addresses. Required — empty trusts every sender, so anyone who reaches those ports could claim any address and get past bans, rate limits and allow lists. A file that leaves it empty still loads (a server that ran keeps running), with a warning at start and from arka check; the panel refuses to save it. |
[admin]#
The control panel, its REST/SSE API, GET /metrics and GET /healthz.
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
|
listen |
"127.0.0.1:9898" |
Use "0.0.0.0:9898" to reach it from other machines — together with allow and tls. |
token |
— | The owner's access token. Otherwise ARKA_ADMIN_TOKEN, otherwise a random token saved to .admin-token (print it with arka token). |
allow |
[] |
Addresses or networks allowed to connect (["127.0.0.1", "192.168.1.0/24"]). Empty lets anyone who can connect try to sign in. |
tls |
— | { self_signed = true }, or { cert = "…", key = "…" }. Plain-HTTP requests to an HTTPS panel are redirected. Certificate files are read again when they change (looked at once a minute), so the panel can use one Arka's ACME client keeps — see below. |
public_metrics |
false |
Let Prometheus scrape /metrics without signing in. |
file_manager |
false |
The Files page: browse, edit, upload, download, rename and delete any file the Arka process can reach. See the warning below. |
token_local_only |
false (true in the .deb's arka.toml) |
Accept the access token only from this machine (an SSH tunnel counts) once an admin account with two-step sign-in — an authenticator app or a passkey — exists. The token skips two-step sign-in, so a leaked token is then useless from anywhere else. Until such an account exists it works from anywhere, so a fresh install can still be set up from a browser. |
Rotating the token. arka token --rotate writes a new random token to .admin-token and makes the running server switch to it at once (arka reload); signed-in accounts stay signed in. A token set in arka.toml or ARKA_ADMIN_TOKEN is changed there instead.
The panel under its own name, with a real certificate. Point a name at the server (panel.example.com), give it a host so Arka's ACME client gets and renews a certificate for it — the host can just send visitors on to the panel — and let the panel use that certificate:
# hosts/panel.example.com.toml
server_names = ["panel.example.com"]
[tls]
acme = true
redirect_http = true
[[routes]]
path = "/"
handler = { type = "redirect", to = "https://panel.example.com:9898{uri}" }
# arka.toml
[admin]
tls = { cert = "/var/lib/arka/acme/certs/panel.example.com.pem", key = "/var/lib/arka/acme/certs/panel.example.com.key" }
Until the first certificate is issued, the panel serves a self-signed one; it switches over within a minute of the files appearing, and follows every renewal.
The file manager reaches the whole filesystem — private keys, the panel's own secrets, system files — so a panel compromise becomes a whole-host compromise once it is on. It is off by default; turn it on only for a panel protected by allow, tls and a strong token. Only admin accounts can use it (even to read), every change is written to the audit log, deletions are permanent, uploads are limited to 1 GiB, and the in-panel editor opens UTF-8 text files up to 2 MB (anything else can be downloaded). It acts with the permissions of the user Arka runs as.
Settings only the server can change. Some settings would let whoever sets them reach past the panel — run programs, read every file, or skip two-step sign-in. The panel shows them but cannot change them, so a stolen panel session stays a panel session; change them in arka.toml on the server, then restart:
| Setting | Why |
|---|---|
admin.file_manager |
It gives the panel every file on the machine. The panel may switch it off. |
admin.token_local_only |
The access token skips two-step sign-in. The panel may switch it on. |
admin.token |
Never shown in the panel, and kept as it is when the panel saves. |
acme.dns_hook |
It names a program Arka runs. |
update.url, update.public_key |
They choose where new versions come from, and who may sign them. |
server.web_roots, server.allow_sockets |
They decide which folders sites may publish, and which machine-controlling sockets they may reach. |
arka.toml and host files are written by the panel crash-safely (a temporary file, flushed, then renamed), keeping the permissions they had; new ones are readable by their owner and group only (0640), since host files can hold passwords and keys. The .deb makes /etc/arka readable by root and Arka only, on install and on every upgrade.
Ten failed sign-ins within ten minutes lock an address out for fifteen minutes. Every change made through the panel — with the name of who made it — and every failed sign-in is written to <logs_dir>/admin-audit.log and shown under Server → Activity.
[admin]
listen = "0.0.0.0:9898"
allow = ["127.0.0.1", "192.168.0.0/16"]
tls = { self_signed = true }
Panel accounts#
The access token is the owner's key. For everyone else, create accounts under Server → Users (signed in with the token or as an admin). Each account has a role:
| Role | Can |
|---|---|
| viewer | see everything — hosts, logs, metrics, certificates — and change nothing |
| editor | also create and change hosts, streams, snippets and certificates, purge caches, and drain or disable upstreams |
| admin | also manage users and server settings |
No host can reach Arka's own files, whoever wrote it: web roots, error pages, log files, scripts, password files and disk caches may not point into (or contain) arka.toml, the admin token, the hosts, streams and snippets folders, state_dir (accounts, private keys, certificates) or the files arka.toml names (the panel's certificate, the ACME DNS hook) — symbolic links included — and web roots may not serve logs_dir. So an editor can change what the sites do, but cannot read the panel's secrets through one or make themselves an admin.
What hosts cannot reach#
Nor can a host reach past the sites, whoever wrote it:
- The system's folders are never served —
/proc(every process's environment),/sys,/dev,/boot,/root,/etc,/run,/usrand the system's binaries and libraries,/var/lib,/var/log,/var/spool,/var/mail,/var/backups,/var/cache— nor a folder that holds them (/,/var). An error page may still come from/usr(/usr/share/nginx/html/50x.html); a disk cache may live in/var/cache. - A log file is never named like a script (
.php,.phtml,.cgi…): a request logged into a PHP site's folder would otherwise run as code. - Sockets that control the machine are never proxied to — Docker's, containerd's, Podman's, CRI-O's, systemd's, D-Bus, libvirt's, snapd's, LXD's — by proxy, FastCGI, mirror or canary upstreams, or streams: one site proxying to the Docker socket would give anyone on the internet root. A site that really must is listed in
server.allow_sockets. - With
server.web_roots, everything served lies inside the folders it lists.
These settings live in arka.toml, which the panel cannot change them in: an editor's mistake — or a stolen editor session — stays inside the sites.
Backups, the server log#
Server → Backups makes an archive like arka backup (settings, sites, panel accounts, certificates), downloads one — keep a copy off the machine — uploads one made elsewhere, and restores one: what is there is archived first (arka-before-restore-…), the archive is checked (only plain files and folders, nothing outside its own tree, an arka.toml that loads), its parts go to this server's own places, and Arka restarts without dropping a connection. Server → Server log shows Arka's own log since it started — the latest 1,000 lines, warnings and errors or everything — for when the journal is out of reach.
Passwords are hashed with bcrypt and kept in <state_dir>/users.json. Anyone can turn on two-step sign-in under Account: scan the QR code with an authenticator app (Google Authenticator, 1Password, Aegis…) and confirm a code; from then on signing in asks for a six-digit code too. An admin can reset it for someone who lost their phone. Sessions last twelve hours and are sent as a bearer token, never as a cookie, so the panel is immune to CSRF.
Passkeys. Under Account → Passkeys, anyone can add up to ten passkeys — a phone, a laptop's fingerprint reader or face unlock, a security key such as a YubiKey — after confirming their password, and then sign in with Sign in with a passkey on the sign-in page: no user name or password, and nothing a look-alike site could capture, because a passkey only ever answers the domain it was made for. The device must verify its owner (PIN, fingerprint or face), so a passkey sign-in is a two-step sign-in and skips the authenticator-app code. Each signature is checked against the passkey's public key (ES256, EdDSA or RS256), its challenge works once and for two minutes, and a signature counter that goes backwards — a copied authenticator — is refused. Passkeys are kept with the account in users.json (only public keys) and reach every node of a cluster. Failed passkey sign-ins count towards the same lockout as wrong passwords, and adding, removing and using them is audited.
Browsers allow passkeys only for a site with a domain name, over HTTPS (or at localhost): a panel reached at an IP address such as https://192.168.0.10:9898 cannot use them — give it a name (for example a host that proxies panel.example.com to it). A passkey belongs to the name it was made at.
[acme]#
Automatic certificates from Let's Encrypt or any ACME CA, for hosts with tls.acme = true. The renewer runs on a thread of its own and starts again a minute later should it ever crash; Prometheus can alert on arka_certificate_expiry_timestamp_seconds and arka_certificate_renewal_failing (see metrics).
| Key | Default | Meaning |
|---|---|---|
email |
— | Where the CA sends expiry warnings (recommended). |
ca |
"letsencrypt" |
"letsencrypt", "letsencrypt-staging" (for trying things out — browsers do not trust it), "zerossl", or any ACME directory URL. |
challenge |
"http-01" |
"http-01" (port 80 must reach Arka), "tls-alpn-01" (port 443 must) or "dns-01" (through dns_hook — the only way to get *.example.com). |
dns_hook |
— | For dns-01: a program run as <hook> add\|remove <record> <value> to create and delete the _acme-challenge TXT record. Set in arka.toml only: the panel cannot change which program Arka runs. |
dns_propagation |
"60s" |
For dns-01: how long to wait for the record to spread. |
eab_kid, eab_hmac_key |
— | External account binding (ZeroSSL, Google Trust Services…). |
renew_before |
"30d" |
Renew this long before expiry — or when a third of the certificate's lifetime is left, if that is sooner (so 6-day certificates renew on day 4). |
ca_root |
— | A CA certificate (PEM) to trust for the ACME server itself — a private CA, or Pebble for testing. |
Until the first certificate arrives a self-signed one stands in. Certificates live in <state_dir>/acme/ and are swapped into the running host on renewal — no reload, no dropped connection. The Certificates page shows every certificate, its expiry, its OCSP status and a Renew now button.
Renewal information (RFC 9773). When the CA offers it — Let's Encrypt does — Arka asks it every few hours when each certificate should be renewed, and renews at a random moment within the window it suggests; a window that has already begun means now, which is how a CA gets certificates replaced quickly after a mass revocation. A renewal also names the certificate it replaces. CAs without renewal information are renewed by renew_before, as before. The Certificates page shows the CA's suggestion and, when it gave one, its explanation.
Profiles. CAs may offer several kinds of certificate; a host picks one with acme_profile in its [tls] — at Let's Encrypt, "shortlived" gives 6-day certificates (which need no revocation: a stolen key is useless within days), "tlsserver" the newer default. Changing the profile gets a new certificate right away.
[acme]
email = "ops@example.com"
# hosts/shop.toml
[tls]
listen = [":443"]
acme = true
redirect_http = true
[geoip]#
| Key | Default | Meaning |
|---|---|---|
database |
— | A country database in .mmdb format: MaxMind GeoLite2/GeoIP2 Country or City, or DB-IP. Replacing the file takes effect on reload. |
It enables [geo] rules, {country} in templates and logs, and country route conditions.
[tracing]#
OpenTelemetry traces of every request, sent over OTLP/HTTP to Jaeger, Grafana Tempo, Honeycomb, the OpenTelemetry Collector… Upstreams receive a W3C traceparent header, so their spans join the same trace.
| Key | Default | Meaning |
|---|---|---|
endpoint |
— | The collector, e.g. "http://localhost:4318"; spans go to <endpoint>/v1/traces. Unset: tracing is off. |
service_name |
"arka" |
How Arka appears in traces. |
sample_rate |
1.0 |
Share of new traces recorded, 0–1. Requests continuing a trace keep its decision. |
trust_traceparent |
true |
Continue traces started by clients or proxies in front. Turn off on servers facing the internet. |
headers |
{} |
Extra headers for the collector, e.g. { "x-honeycomb-team" = "${HONEYCOMB_KEY}" }. |
Each span carries the method, route, status, client address, upstream, cache result and country. The trace id is available as {trace_id} in custom log lines.
[cluster]#
Several Arka servers working as one. The primary's configuration — hosts, streams, snippets, panel accounts, ACME certificates and the sign-in key — is copied to the replicas; cache purges, upstream drains, bans and (optionally) rate-limit counts are shared by all.
| Key | Default | Meaning |
|---|---|---|
secret |
— | The same long random string on every node. Unset: no cluster. |
name |
the machine's host name | This node's name in the panel. |
peers |
[] |
The other nodes' control panel addresses: ["10.0.0.2:9898", "10.0.0.3:9898"]. |
primary |
— | On replicas: the primary's panel address. Leave it out on the primary. |
sync_interval |
"5s" |
How often replicas fetch the configuration. |
share_rate_limits |
false |
Share rate-limit counts every second, so a limit holds for the cluster as a whole. |
share_bans |
true |
Send every ban made or lifted on this node to the others, and apply theirs. |
Nodes talk to each other's panel port under /cluster/v1/; every request is signed and every snapshot encrypted with keys derived from secret, and requests older than a minute are refused. On a replica the panel is read-only (a banner says so) — make changes on the primary. Pending ACME http-01 answers are shared, so the CA's check succeeds whichever node it reaches. The Cluster page shows every node, its version, its last sync and its traffic.
# arka.toml on the primary (10.0.0.1)
[cluster]
secret = "${ARKA_CLUSTER_SECRET}"
peers = ["10.0.0.2:9898"]
share_rate_limits = true
# arka.toml on a replica (10.0.0.2)
[cluster]
secret = "${ARKA_CLUSTER_SECRET}"
primary = "10.0.0.1:9898"
peers = ["10.0.0.1:9898"]
share_rate_limits = true
The panel port must be reachable between the nodes (set admin.listen and admin.allow accordingly).
[update]#
New versions of Arka from a signed release feed. The panel (Server → Updates) shows what is new, with the release notes, and installs it in one click: the new binary takes over the open sockets while the old one finishes its requests, so no visitor is disconnected. The previous binary is kept as <binary>.prev, and Roll back swaps them again.
| Key | Default | Meaning |
|---|---|---|
check |
true |
Look for new versions in the background. Only the current version and machine type are sent. |
interval |
"6h" |
How often. |
channel |
"stable" |
Which feed to follow ("stable", "beta"…). |
url |
built in | The feed; {channel} is replaced. Official builds have it built in. Set in arka.toml only (the panel cannot change it). |
public_key |
built in | The base64 Ed25519 key releases must be signed with — or several, separated by commas: a release signed by any of them is accepted, so the signing key can change (or a leaked one be retired) without stranding a server. Official builds have it built in. Set in arka.toml only (the panel cannot change it). |
install |
"auto" |
"auto" (directly when Arka may write its own binary, otherwise through the root helper), "direct", "helper", or "notify" (only say that an update is available). |
Every release is signed. The signature covers the channel, version, machine type, size and SHA-256 of the binary and the release notes; a download that does not match is refused, whatever the feed says, and versions not newer than the running one are never installed — an old, validly signed release cannot be replayed to downgrade a server. Feeds must be HTTPS.
Who may install. When Arka may replace its own binary (a tarball or source install), it does so itself. When it may not — the usual case for a package install, where /usr/local/bin/arka belongs to root and Arka runs as the unprivileged arka user — it verifies the download, leaves it in <state_dir>/updates with a request, and the root helper takes over: enable arka-update.path and arka-update.service. That helper trusts nothing it finds in the folder: it copies the file into a root-owned temporary file, verifies that copy against the key built into the installed binary (never a key from arka.toml), refuses symbolic links and downgrades, and only then renames it into place. So even someone who took over the server process could not use updates to get root — they would need the release key. Without the helper, the panel only says that an update is available.
Publishing releases (on the release machine, which keeps the private key offline):
arka release keygen --out release.key # once; prints the public key
ARKA_UPDATE_PUBKEY=<public key> \
ARKA_UPDATE_URL=https://updates.example.com/arka/{channel}.json \
make pgo # official builds carry both
arka release sign --key release.key --binary target/release/arka \
--version 0.2.0 --url https://updates.example.com/arka/0.2.0/arka-x86_64-linux \
--notes NOTES.md --manifest stable.json # once per machine type
arka release verify --manifest stable.json --binary target/release/arka --public-key <public key>
Upload the binary and stable.json to any static host (a CDN, object storage, or an Arka host).
[bans]#
Addresses that keep misbehaving are refused at the door for a while: a banned client's connections — to every site and every TCP stream — are closed as soon as they are accepted (no TLS handshake, no request read), its UDP datagrams are dropped, and a request on a connection it opened earlier gets 403. While nobody is banned, checking costs one memory read per connection.
Offences earn an address points: a request the firewall refused, a failed sign-in, a rate-limit hit, an HTTP/2 flood (see http2_max_resets in [server]), an upload [antivirus] found infected and, if you give them points, 404s. Enough points within window earn a ban, and every later ban of the same address lasts twice as long, up to max_duration. IPv6 clients are banned by network (a /64), since one client often holds a whole one.
Bans can also be made and lifted by hand, for an address or a network, on the panel's Firewall page or through the API (POST /api/v1/bans, DELETE /api/v1/bans/<address>); those work whether automatic bans are on or not. Bans are kept across restarts and upgrades (in state_dir) and, in a cluster, reach every node within a second.
[bans]
enabled = true
threshold = 20 # points…
window = "10m" # …within this time
duration = "15m" # the first ban; then 30m, 1h… up to max_duration
max_duration = "1d"
never = ["127.0.0.0/8", "::1", "10.0.0.0/8"] # monitoring, offices, load balancers
[bans.points]
waf = 5
auth = 2
rate_limit = 1
not_found = 0 # give 404s points on sites that are probed a lot
abuse = 20
malware = 10 # an infected upload: two earn a ban
challenge = 2 # a forged or wrong answer to a bot check
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Ban automatically. |
threshold |
20 |
Points within window that earn a ban. |
window |
"10m" |
|
duration |
"15m" |
The first ban of an address. |
max_duration |
"1d" |
The longest ban; each one lasts twice as long as the one before. |
ipv6_prefix |
64 |
The network IPv6 clients are banned by. |
never |
["127.0.0.0/8", "::1"] |
Never banned, automatically or by hand. Your own list replaces the default. |
persist |
true |
Keep bans across restarts. |
points |
above | What each offence is worth; 0 ignores it. |
Before turning automatic bans on, make sure Arka sees real client addresses. Behind a load balancer or a CDN, set up real_ip or the PROXY protocol (proxy_protocol in [server]), and clients are banned by their own addresses. A trusted proxy is never punished for what it forwards: a request that comes from the proxy itself earns no points, and an HTTP/2 flood on a proxy's connection closes that connection without a ban. Networks wider than /8 (IPv4) or /16 (IPv6) cannot be banned. The control panel listens on its own port and is never affected.
Refused connections and requests are counted in arka_http_events_total{event="banned"}.
[antivirus]#
Files uploaded to WebDAV shares and through the panel's file manager are scanned by ClamAV while they arrive — streamed to clamd as they are written, not read back afterwards — and stored only when found clean: the upload goes to a hidden file beside its name and is moved into place at the end, so an infected (or half-sent) file never appears in the folder, and an existing file it would replace is kept. The text editor's saves are scanned too.
With scan_apps = true, uploads to apps are scanned too — behind type = "proxy" and type = "fastcgi" (PHP: WordPress media, form uploads) routes. Each request body is read whole — into memory, then a temporary file in state_dir — while clamd scans it, and the app sees none of it before the verdict, so an infected file never reaches it. Every body is scanned, whatever its Content-Type claims (a file is just as harmful labelled text/plain); gRPC (application/grpc…) is left alone, since its streams cannot be held back until they end. The cost: uploads no longer stream through to the app, and every request body is one more clamd scan.
An infected upload is answered 403, shown on the panel's Security page (as Malware, with the signature) and in the log, and earns the client points.malware towards a ban. Turn it on, and choose what happens when the scanner cannot vouch for a file, under Server configuration → Virus scanning in the panel.
[antivirus]
clamd = "unix:/run/clamav/clamd.ctl" # apt install clamav-daemon; or "127.0.0.1:3310"
on_error = "reject" # when clamd is down or slow, or the file is too large
max_size = "25MB"
timeout = "30s"
scan_apps = true # uploads to proxied and PHP apps too
| Key | Default | Meaning |
|---|---|---|
clamd |
"" |
Where clamd listens: "unix:/path" or an address with its port. Empty: uploads are not scanned. |
on_error |
"reject" |
An upload the scanner cannot vouch for — clamd is down, answers with an error or too slowly, or the file is over max_size: "reject" fails it (503, or 413 when too large) and stores nothing; "allow" stores it unscanned and logs a warning. Infected files are refused either way. |
max_size |
"25MB" |
Larger uploads are not scanned (and fall under on_error). Keep it at or below clamd's StreamMaxLength. |
timeout |
"30s" |
How long clamd may take to answer once the upload is complete. |
scan_apps |
false |
Also scan request bodies sent to apps (proxy and FastCGI routes), before they are passed on. Under on_error = "allow", a body over max_size is passed on unscanned as it arrives. |
The arka user needs to reach clamd's socket: on Debian and Ubuntu, sudo usermod -aG clamav arka (then restart Arka), or let clamd listen on 127.0.0.1:3310 (TCPSocket 3310, TCPAddr 127.0.0.1 in /etc/clamav/clamd.conf).
[logs]#
Arka rotates its own log files — every host's access log and the panel's audit log — so they never fill the disk, and keeps room free on the disks it writes to.
| Key | Default | Meaning |
|---|---|---|
rotate |
true |
Rotate log files. Turn it off only when another tool (logrotate) rotates them; it then runs arka reopen. |
max_size |
"100MB" |
A log is rotated when it reaches this size… "0": never by size. |
every |
"1d" |
…and at least this often, unless it is empty. "0s": by size only. |
keep |
14 |
Rotated files kept for each log; older ones are deleted. |
compress |
true |
gzip rotated files — all but the newest, which a process finishing its requests during an upgrade may still be writing to. |
disk_reserve |
"1GB" |
Free space kept on the disks Arka writes to (logs, state, configuration) — at most a tenth of the disk. Below it, access logs pause, so certificates, panel accounts and settings can still be saved, until there is twice as much free again; the panel's Needs attention list and an alert say so. "0": never pause. |
A rotated log is named after the moment it was rotated, in UTC: shop.access.log-20261029-060000, then shop.access.log-20261028-060000.gz. The audit log is rotated by size only — an audit trail is not dropped after a couple of weeks. Lines not written while logs are paused are counted in arka_access_log_lines_skipped_total; syslog targets and the panel's live view carry on.
[alerts]#
A message when something needs a person, POSTed as JSON to a webhook:
| Event | Level | When |
|---|---|---|
certificate.failed |
error | A certificate cannot be obtained or renewed — at the first failure, then about once a day while it keeps failing. |
certificate.renewed |
ok | One is obtained after failing. |
certificate.expiring |
warning, error | A certificate expires within 14, 7, 3 or 1 days, or has expired — once at each step (checked every six hours). |
upstream.down, upstream.up |
error, ok | An upstream or PHP server fails its health checks (health_check) and leaves rotation, or passes them again. |
disk.low, disk.ok |
error, ok | A disk Arka writes to has less free than [logs] disk_reserve, or has room again. |
firewall.spike |
warning | The firewall of a host refused 50 requests or more in five minutes, at least a tenth of them: an attack — or a rule refusing real visitors. |
firewall.bans |
warning | Five addresses or more banned automatically within ten minutes. |
| Key | Default | Meaning |
|---|---|---|
webhook |
— | Where alerts are sent (https://…). Unset: no alerts. |
secret |
— | Signs each alert: X-Arka-Signature: sha256=<hex HMAC-SHA256 of the body>, so the receiver can tell it came from this server. |
events |
[] |
Which to send: "certificate", "upstream", "disk", "firewall", or single events such as "upstream.down". Empty: all. |
{"event":"upstream.down","level":"error","host":"shop","message":"10.0.0.5:8000 failed 3 health checks in a row and is out of rotation","server":"web1","time":"2026-10-29T06:00:00.000Z","text":"[web1] shop: 10.0.0.5:8000 failed 3 health checks in a row and is out of rotation"}
text is the whole alert in one line, so chat webhooks that show a text field — Slack-compatible ones, which Mattermost, Rocket.Chat and Discord (with /slack after the webhook URL) accept — show it as is. Alerts go one at a time, in order; a receiver that cannot be reached, or answers 5xx or 429, gets two more tries (after 2 and 10 seconds). A flood is cut to 20 alerts a minute, and the next one sent says how many were left out (left_out). Every request carries X-Arka-Event. Server configuration → Alerts in the panel sets them up and sends a test alert.
Host files#
The file name is the host id: hosts/shop.toml is host shop. Hosts deleted from the panel wait in hosts/.trash/; Hosts → Deleted brings one back (checked first, like a new one). A minimal host:
server_names = ["shop.example.com"]
listen = [":80"]
[[routes]]
path = "/"
handler = { type = "static", root = "www/shop" }
Names and listeners#
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Disabled hosts stay on disk but are not served. |
description |
— | Shown in the panel. |
include |
[] |
Snippets merged into this host: ["snippets/security.toml"]. |
server_names |
[] |
Names this host answers to (see below). |
listen |
[":80"] |
Plain-HTTP addresses: ":8080", "0.0.0.0:80", "[::]:80", "127.0.0.1:8080". Left out, the host listens on port 80 on every address; listen = [] turns plain HTTP off. |
default |
false |
Answer requests whose Host matches no other host on the same address. |
request_id |
true |
Give every request an X-Request-Id: echoed in the response, sent upstream and written to the access log. An incoming id is kept only when it comes from a trusted proxy (real_ip). |
Server names are matched like nginx's server_name, in this order:
- exact names —
example.com - the longest leading wildcard —
*.example.com - the longest trailing wildcard —
www.example.* - regular expressions, in file order —
~^(?<shop>[a-z]+)\.example\.net$(matched against the lowercased name) - the
defaulthost of the address
Two hosts may not claim the same name on the same address, and an address is either HTTP or HTTPS. When a new file conflicts with a running host, the running host keeps the name.
Tip: a trailing wildcard such as
docs.*makes a site reachable asdocs.192.168.1.20.nip.io— a public DNS name that points to192.168.1.20— so other devices on your network can use name-based hosts without editing their hosts file.
[tls]#
| Key | Default | Meaning |
|---|---|---|
listen |
[":443"] |
HTTPS addresses. Left out, port 443 on every address. |
cert, key |
— | PEM certificate chain (leaf first) and private key (PKCS#8, PKCS#1 or SEC1). |
acme |
false |
Get and renew a certificate automatically — see [acme]. |
acme_profile |
the CA's default | The certificate profile to ask for, when the CA offers several (Let's Encrypt: "shortlived", "tlsserver", "classic"). |
self_signed |
false |
Generate a certificate for the host's names, localhost and this machine's addresses (kept in state_dir/certs). For testing and internal networks; browsers warn. |
redirect_http |
false |
Answer plain-HTTP requests for this host with a redirect to HTTPS. |
hsts |
false |
Send Strict-Transport-Security on HTTPS responses. |
http3 |
false |
Also serve HTTP/3 (QUIC) on the same ports over UDP. Browsers learn about it from Alt-Svc and switch on their next request. Open the UDP port in your firewall. |
min_version |
"1.2" |
Lowest TLS version accepted: "1.2" or "1.3". |
ciphers |
secure defaults | Allowed cipher suites by IANA name, e.g. ["TLS13_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"]. |
ocsp_stapling |
true |
Staple fresh OCSP responses (fetched from the CA, refreshed before they expire) to the handshake, so browsers need not ask the CA. |
client_auth |
— | Mutual TLS, below. |
client_auth — clients present a certificate signed by one of your CAs:
| Key | Default | Meaning |
|---|---|---|
ca |
— | PEM file with the CA certificates client certificates must chain to. |
crl |
[] |
PEM certificate revocation lists (like nginx ssl_crl). A revoked client certificate is refused at the handshake; a certificate no list covers is still accepted — the lists are a deny-list. |
optional |
false |
Let clients without a certificate in (a wrong certificate is still refused). Routes can then check {client_cert.subject} in a condition. |
forward |
true |
Send X-Client-Cert-Subject, -Issuer, -Serial and -Fingerprint upstream. Client-sent copies are always removed. |
Certificates are chosen by SNI, so any number of hosts can share :443. HTTP/2 is negotiated with ALPN, sessions resume with tickets, and a plain-HTTP request sent to an HTTPS port gets a redirect instead of a dead connection. arka reload (or SIGHUP) picks up renewed certificates without dropping connections.
aws-lc. Built with the aws-lc feature (FEATURES=aws-lc), all of incoming TLS — key exchange, signatures, ciphers — runs on aws-lc-rs instead of ring; key exchange stays classical unless pq is on too. Measure it on your hardware before choosing it (see bench/).
Post-quantum key exchange. Arka built with the pq feature (make deb GLIBC=2.17 FEATURES=pq, or cargo build --release --features pq) offers the hybrid key exchange X25519MLKEM768 first on every HTTPS and HTTP/3 listener — what current Chrome, Firefox and Safari offer — so traffic recorded today cannot be decrypted later by a quantum computer. Browsers that do not offer it get X25519 as before; certificates and everything else are unchanged. The startup log says TLS: post-quantum key exchange (X25519MLKEM768) offered first. The cost: about 2 MB more binary (aws-lc, built with cmake, provides ML-KEM), and a new connection's handshake takes about half a millisecond more — measured at roughly 12% fewer brand-new connections per second at full load, which kept-alive and resumed connections do not pay. The portable build (GLIBC=2.17) compiles aws-lc with zig for the old glibc, so it still runs on every Debian and Ubuntu in use.
[[routes]]#
Routes map paths to handlers. Like nginx locations: exact matches first, then regular expressions in file order, then the longest prefix. A prefix ending in / also matches the path without it (/dav/ matches /dav).
| Key | Default | Meaning |
|---|---|---|
path |
— | A prefix ("/api/"), an exact path or a regular expression, depending on match. |
match |
"prefix" |
"prefix", "exact" or "regex". |
when |
[] |
Extra conditions that must all hold; otherwise matching moves on to the next candidate. |
handler |
— | What happens — see handlers. |
methods |
[] |
Allowed methods (["GET", "POST"]); others get 405 with an Allow header. GET implies HEAD. |
access |
— | IP rules for this route (like [access]), checked after the host's. |
geo |
— | Country rules for this route (like [geo]), checked after the host's. |
rate_limit |
— | A rate limit for this route, on top of the host's. |
connections |
— | Simultaneous requests per client (like [connections]). |
bandwidth |
— | Response speed (like [bandwidth]); replaces the host's. |
waf |
— | Firewall settings (like [waf]); replace the host's — { enabled = false } turns it off here. |
auth |
— | Replaces the host's [auth]; { type = "none" } makes the route public. |
cors |
— | Replaces the host's [cors]. |
signed |
— | Only signed, unexpired links get through. |
headers |
— | Response header rules for this route (like [headers]). |
body_replace |
[] |
Text replaced in responses — see body_replace. |
script |
— | Run a Rhai script's on_request / on_response around the handler: { file = "scripts/gate.rhai" }. |
internal |
false |
Only reachable through internal redirects (a proxy's X-Accel-Redirect) and server-side includes; visitors get 404 (nginx internal). |
Route conditions#
when makes a route match only for some requests — a beta for testers, an API version by header, a page for one country:
[[routes]]
path = "/"
when = [{ source = "cookie", name = "beta", op = "equals", value = "1" }]
handler = { type = "proxy", upstreams = ["127.0.0.1:4000"] }
[[routes]]
path = "/" # everyone else
handler = { type = "proxy", upstreams = ["127.0.0.1:3000"] }
| Key | Meaning |
|---|---|
source |
"header", "query" (a query parameter), "cookie", "method", "client_ip", "country", "host", "scheme" or "variable" (a host variable). |
name |
The header, parameter, cookie or variable name. |
op |
"equals" (default), "not_equals", "prefix", "suffix", "contains", "regex", "exists", "missing", "in", "not_in". |
value |
What to compare with. For in / not_in, a comma-separated list — networks such as "10.0.0.0/8, 192.168.1.5" for client_ip. |
Handlers#
type = "static" — files from disk#
| Key | Default | Meaning |
|---|---|---|
root |
— | Directory to serve; the request path is appended to it (nginx root). |
index |
["index.html"] |
Files tried for a directory. |
browse |
false |
List directories without an index file. |
fallback |
— | File served (status 200) when nothing matches — "index.html" for single-page apps. |
try_files |
[] |
Paths tried in order, like nginx try_files: templates such as "{path}", "{path}.html", "{path}/" (a directory's index), then a last resort — a path ("/index.html") or a status ("=404"). |
strip_prefix |
false |
Remove the route's prefix before looking up files (nginx alias). |
precompressed |
true |
Serve file.br / file.gz siblings when the client accepts them. |
show_hidden |
false |
Serve dotfiles such as .env (.well-known is always served). |
follow_symlinks |
false |
Serve through symbolic links that lead out of root. Off, a link may point anywhere inside the root (css/latest -> v3), but one leading elsewhere (/etc, another site, ../storage) answers 404. Turn it on for layouts that link out on purpose, like Laravel's public/storage. |
cache_control |
— | Cache-Control value, e.g. "public, max-age=3600". |
cache |
on | In-memory cache: { enabled, max_file_size = "1MB", max_size = "64MB", revalidate = "1s" }. Larger files are not held in memory but kept open between requests (up to 256 per route, closed after a minute unused), like nginx's open_file_cache. Either way a file is checked against the disk at most once per revalidate, and one replaced by another — even with the same size and date — is noticed. Each worker answers hits through handles of its own, so cores share no locks or counters; while the cache is evicting heavily, files those handles still hold can briefly take memory up to 1.5 × max_size. |
ssi |
false |
Process server-side includes in HTML files (below). |
mp4 |
false |
Let players jump into videos (below). |
images |
— | Resize and convert images on request (below). |
Responses carry ETag and Last-Modified; conditional (304) and range (206) requests are supported. Compressed variants of small files are built once and kept.
Server-side includes (ssi = true) — the subset nginx and Apache sites rely on: <!--#include virtual="/nav.html" --> (any path of the host, through its routes) and file="footer.html" (relative to the page), <!--#echo var="DATE_LOCAL" -->, <!--#set var="title" value="…" -->, <!--#if expr="$lang = /^en/" --> … elif / else / endif, and <!--#config errmsg="…" -->. Includes nest up to eight levels.
MP4 seeking (mp4 = true) — movie.mp4?start=120&end=180 answers with a complete, playable MP4 holding just that part, like nginx's mp4 module. The clip starts at the key frame at or before start, so it decodes cleanly; only the index is rewritten and the media is sent straight from the file. Range requests work on the clip too.
Images (images = {…}) — photo.jpg?w=400, ?w=800&h=600&fit=cover, ?format=webp&q=75:
| Parameter | Meaning |
|---|---|
w, h |
The box to fit in (never larger than the original). |
fit |
contain (default: keep proportions, fit inside), cover (fill the box, crop the middle) or fill (stretch). |
format |
jpeg, png, webp or auto (WebP for browsers that accept it, when it is smaller). |
q |
JPEG quality, 1–100. |
| Key | Default | Meaning |
|---|---|---|
max_width, max_height |
4096 |
The largest size produced. |
widths |
[] |
Allowed widths; requests are rounded up to the next one, so visitors cannot make (and the cache hold) thousands of sizes. Empty: any width up to max_width. |
quality |
82 |
JPEG quality when the request does not say. |
cache_size |
"64MB" |
Memory for resized images. |
Resizing runs on background threads, at most one per CPU at a time, and decoding is bounded, so a hostile "image bomb" fails instead of eating all memory.
[[routes]]
path = "/media/"
[routes.handler]
type = "static"
root = "www/media"
mp4 = true
images = { widths = [320, 640, 1280, 1920] }
cache_control = "public, max-age=86400"
type = "proxy" — reverse proxy and load balancer#
[[routes]]
path = "/api/"
[routes.handler]
type = "proxy"
upstreams = ["10.0.0.11:8080 weight=3", "10.0.0.12:8080", "10.0.0.13:8080 backup"]
strategy = "least_conn"
strip_prefix = true
retry_on = ["error", "timeout", "502", "503"]
health_check = { path = "/healthz", interval = "5s" }
cache = { max_size = "256MB", default_ttl = "30s", stale_while_revalidate = "1m" }
slow_start = "30s"
| Key | Default | Meaning |
|---|---|---|
upstreams |
[] |
"host:port", "http://…", "https://…" or "unix:/run/app.sock", optionally followed by weight=N (1–1000), max_conns=N (most requests in flight at once) and/or backup (used only when every other upstream is down). |
strategy |
"round_robin" |
"round_robin" (smooth weighted, like nginx), "least_conn" (fewest in flight per unit of weight), "least_time" (fewest in flight weighted by how fast each upstream has been answering lately — a peak EWMA of the time to response headers, so an upstream that starts to struggle loses traffic at once; nginx Plus least_time), "p2c" (power of two choices: the less loaded, by least_time's measure, of two upstreams drawn at random — what Linkerd and Finagle use), "ip_hash" (sticky per client), "hash" (consistent hashing of hash_key), "random". Busy (max_conns), down and ejected upstreams are skipped by every strategy. |
hash_key |
"{uri}" |
What "hash" hashes — any template, e.g. "{cookie.session}" or "{header.X-Tenant}". |
strip_prefix |
false |
Remove the route's prefix before forwarding. |
preserve_host |
false |
Send the client's Host instead of the upstream's address. |
websocket |
true |
Proxy WebSocket upgrades. |
connect_timeout |
"5s" |
Also the longest a name lookup may take. Upstream host names are looked up again every 30 seconds; when a lookup fails or takes too long, the last answer keeps being used for up to an hour (logged), so a DNS outage does not take a running backend down with it. |
timeout |
"60s" |
Time to wait for the upstream's response headers, counted from when the whole request has been sent — a slow upload never makes a healthy upstream look slow. |
read_timeout |
"60s" |
Longest pause between two reads of the upstream's response body (nginx proxy_read_timeout); a stalled upstream is cut off. |
send_timeout |
"60s" |
Longest pause between two pieces of the client's request body (nginx client_body_timeout); the client gets 408. |
websocket_idle_timeout |
"1h" |
Close WebSocket (and other upgraded) connections after this long without traffic either way. |
keepalive_timeout |
"60s" |
Close pooled upstream connections after this long unused. Keep it below the upstream's own keep-alive timeout; "0s" opens a new connection per request. |
max_fails, fail_timeout |
3, "10s" |
Passive health checks: after max_fails failed attempts an upstream rests for fail_timeout. |
slow_start |
— | Ease an upstream back in over this long after it was down, instead of sending it its full share at once. |
max_conns |
0 |
Most requests in flight at once on each upstream without its own max_conns=N (0: no limit) — e.g. the number of gunicorn or PHP-FPM workers. Busy upstreams are skipped; when every one is busy, requests wait in the queue (or get 503 without one). The limit holds across all worker threads. |
outlier |
— | Eject upstreams that keep failing even though they accept connections, below. |
queue |
— | { size = 100, timeout = "30s" }: hold up to size requests in line, first come first served, while every upstream is at its max_conns (HAProxy's queue); a full queue or a longer wait gets 503 with Retry-After. |
retries |
2 |
Other upstreams to try after a failure — see retries for what is safe to send twice. |
retry_on |
["error"] |
Failures worth a retry: "error" (cannot connect, or the connection broke before an answer), "timeout", "429", "500", "502", "503", "504", plus "non_idempotent" to also retry POST, PATCH and LOCK requests that may have reached the upstream. |
retry_timeout |
— | Stop retrying once this much time has passed since the first attempt (nginx proxy_next_upstream_timeout). |
tls_verify |
true |
Verify https:// upstream certificates. |
tls_ca |
— | Extra CA (PEM) to trust, e.g. an internal CA. |
tls_client_cert, tls_client_key |
— | A client certificate for upstreams that require one (mutual TLS). |
http2 |
false |
Speak HTTP/2 to the upstreams — needed for gRPC (h2c for http://, ALPN for https://; trailers pass through). |
request_headers |
— | { set = {…}, remove = […] } applied to the request sent upstream; values are templates. |
response_headers |
— | { set = {…}, remove = […] } applied to the upstream's answers, cached ones included (nginx proxy_hide_header / add_header), e.g. { remove = ["X-Powered-By"] }. |
path_prefix |
— | Put in front of the path sent upstream, after strip_prefix: with path = "/api/", strip_prefix = true and path_prefix = "/v2", /api/users goes to /v2/users (nginx proxy_pass http://app/v2/). |
rewrite_redirects |
true |
Rewrite Location, Content-Location and Refresh headers that point at an upstream's own address (http://127.0.0.1:8000/login/) so they point at this site, give relative paths back the prefix strip_prefix removed, and take path_prefix off (nginx proxy_redirect default). Addresses elsewhere are left alone. |
redirects |
[] |
More rewrites, tried first: [{ from = "http://backend.internal/", to = "https://example.com/" }] — a value starting with from gets to instead (nginx proxy_redirect from to). |
cookies |
— | Rewrite the upstream's Set-Cookie headers, below. |
via |
false |
Add a Via header both ways, and answer 508 Loop Detected to a request that has already passed through this Arka (an upstream pointing back at it). |
forward_headers |
true |
Send X-Forwarded-For, X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port upstream. Turn it off when the application does not use them. |
forwarded |
false |
Also send the standard Forwarded header (RFC 7239). |
accel_redirect |
false |
Let the upstream answer X-Accel-Redirect: /path to have Arka serve that path instead — see below. |
server_timing |
false |
Add a Server-Timing header — upstream;dur=12.3;desc="10.0.0.2:8080", cache;desc=MISS — that browsers' developer tools show. It names the upstream, so keep it to sites where that is no secret. |
intercept_errors |
false |
Replace the upstream's error answers (status 400 and above) with this host's error pages, keeping WWW-Authenticate, Retry-After, Allow and Set-Cookie (nginx proxy_intercept_errors) — hides framework debug pages. |
buffering |
— | Read whole bodies so slow clients never keep an upstream busy, below. |
health_check |
— | Active health checks, below. |
cache |
— | Response cache, below. |
sticky |
— | Keep each visitor on one upstream, below. |
canary |
— | Send a share of visitors to other upstreams, below. |
mirror |
— | Copy requests to another server, below. |
discovery |
— | Find the upstreams in DNS, Docker, Consul or Kubernetes, below. |
Every proxied request carries X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Real-IP, X-Request-Id, X-Forwarded-Prefix (the prefix strip_prefix removed) and, with tracing, traceparent. Clients cannot forge them: unless the connection comes from a proxy trusted by real_ip, whatever the client sent in these headers (and in Forwarded) is replaced — so an application reading the left-most X-Forwarded-For, or trusting X-Forwarded-Host (Django's USE_X_FORWARDED_HOST), cannot be fooled. Behind a trusted proxy its values are kept and extended, so a TLS terminator in front of Arka still reports https. A trusted proxy's X-Forwarded-For chain is cut to its last 30 hops. A client's Proxy header never reaches the app: CGI-style apps would turn it into HTTP_PROXY, which HTTP libraries take as the proxy for their own requests ("httpoxy"). Each worker keeps its own pool of keep-alive connections, shared by the host's proxy routes that reach their upstreams the same way.
X-Accel-Redirect — protected downloads served by Arka, not by the application. The application checks who may download what, then answers with an empty body and X-Accel-Redirect: /protected/report.pdf; Arka serves that path from its own routes — usually an internal one — with ranges (resumable downloads), caching and compression, without keeping an application worker busy. The answer's Content-Type, Content-Disposition, Cache-Control, Expires and Set-Cookie are kept. This is what django-sendfile (SENDFILE_BACKEND = "django_sendfile.backends.nginx"), Rails send_file with X-Accel-Mapping and Laravel expect from nginx:
[[routes]]
path = "/protected/"
internal = true # not reachable from outside
handler = { type = "static", root = "/srv/app/private", strip_prefix = true }
[[routes]]
path = "/"
handler = { type = "proxy", upstreams = ["127.0.0.1:8000"], accel_redirect = true }
A query in the target replaces the client's; without one, the client's is dropped. More than ten redirects in a row are refused with 500.
Draining an upstream for maintenance needs no file edit: on the host's page in the panel, or with POST /api/v1/hosts/{id}/upstreams/state and { "address": "10.0.0.12:8080", "state": "draining" } (optionally "route": "/api/"). A draining upstream takes no new visitors while requests in flight finish and visitors pinned to it by sticky stay; disabled takes nothing at all; enabled puts it back. The state lasts until Arka restarts (in a cluster, on every node). When no upstream is left in rotation, visitors get 503 with Retry-After instead of a request to a server that is being worked on. Editing a host keeps what Arka learned about its upstreams — health, ejections, response times, counters and these states — for routes whose path did not change.
Retries are safe by default. A request is only sent again when its body can be replayed — it has none, it is small (up to 64 KB with a known length, read before the upstream is contacted), or buffering read it whole. A connection that could not be opened never saw the request, so any method moves on to the next upstream. Once a request may have reached an upstream (it timed out, the connection broke, or a retry_on status came back), only idempotent methods are retried, unless retry_on lists "non_idempotent" — so a POST is never processed twice. When an upstream closes a pooled connection just as it is reused (a short upstream keep-alive, like gunicorn's 2 seconds), the request gets one free retry on a fresh connection to the same upstream instead of a 502. If every upstream answers with a retry_on status, the client gets the last real answer (with its Retry-After), not a generic error page. Errors caused by the client — a body over max_body_size, an aborted or stalled upload — are answered 413/400/408 and never count against an upstream.
buffering — bodies are read whole instead of passed on as they trickle in, so a slow client never keeps an upstream busy (nginx proxy_request_buffering and proxy_buffering). This is what gunicorn, Unicorn, Puma and other worker-per-request servers ask of the proxy in front of them: with buffering, a visitor on a slow phone holds a worker for milliseconds, not minutes. buffering = {} turns everything on.
| Key | Default | Meaning |
|---|---|---|
requests |
true |
Read the whole request body before contacting an upstream; it is then sent with a Content-Length, and can be sent again if an attempt fails. |
responses |
true |
Read the upstream's answer as fast as it is sent, whatever the client's speed, so the upstream is free again at once. |
memory |
"256KB" |
Memory for one body; the rest goes to a temporary file under the state directory (removed from the directory as soon as it is created). |
max_size |
"1GB" |
Most of one body held at a time. Larger request bodies are passed on as they arrive; a larger answer waits for the client to catch up. |
Small answers of known size are read whole (so they are compressed in one go). Server-sent events (text/event-stream), gRPC and answers with X-Accel-Buffering: no always stream; X-Accel-Buffering: yes buffers an answer even with responses = false. The header itself is not passed on to the client. Leave requests off for gRPC client streaming.
outlier — outlier detection, as in Envoy: max_fails only notices upstreams that cannot be reached; this also notices ones that answer 5xx (their database is gone), break connections or time out, and takes them out of rotation for a while — longer each time it happens again. When one comes back, slow_start (if set) eases it in. outlier = {} turns it on with these defaults:
| Key | Default | Meaning |
|---|---|---|
consecutive_failures |
5 |
Failures in a row (5xx answers, connection errors, timeouts, stalled bodies) that eject an upstream. |
base_ejection |
"30s" |
How long the first ejection lasts; the second lasts twice as long, and so on. Good behaviour slowly earns the multiplier back. |
max_ejection |
"5m" |
The longest an ejection lasts. |
max_ejection_percent |
50 |
Never eject more than this share of the primaries — but one may always go, and an only upstream never does — so a shared outage cannot empty the pool. |
The host's page in the panel shows each upstream's recent response time, its answers by status class and whether it is ejected; Prometheus has them too.
cookies — rewrite the cookies upstreams set (nginx proxy_cookie_domain, proxy_cookie_path, proxy_cookie_flags):
| Key | Meaning |
|---|---|
domain |
{ "backend.internal" = "example.com" }: Domain rewrites (a leading dot is ignored); an empty value removes the attribute, making a host-only cookie. |
path |
{ "/" = "/app/" }: Path prefix rewrites; the longest match wins. |
secure |
"always" adds Secure, "https" adds it when the visitor uses HTTPS, "never" removes it. |
http_only |
true adds HttpOnly, false removes it. |
same_site |
"strict", "lax" or "none" (which needs secure = "always"). |
health_check — probes every upstream in the background:
| Key | Default |
|---|---|
path |
"/" |
interval |
"10s" |
timeout |
"3s" |
expect_status |
"200-399" (also "200" or "2xx") |
healthy_threshold |
2 consecutive successes to come back |
unhealthy_threshold |
3 consecutive failures to leave the rotation |
host |
— Host header for the probe (set it for applications that check host names, like Django's ALLOWED_HOSTS) |
method |
"GET" (or "HEAD") |
headers |
— extra request headers, e.g. { "X-Health-Token" = "…" } |
expect_body |
— text the answer must contain (first 64 KB), e.g. '"status": "ok"': catches an application that answers 200 while broken |
cache — a shared cache in memory, and optionally on disk (PHP routes take the same settings — see their page cache):
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
|
max_size |
"128MB" |
Memory budget for this route. |
max_entry_size |
"8MB" |
Larger responses pass through uncached. |
default_ttl |
"0s" |
Freshness for 200/301/308 responses that state none (0s caches only responses that do). |
stale_if_error |
"60s" |
Serve an expired copy for this long when the upstream fails or answers 5xx. |
stale_while_revalidate |
"0s" |
Serve an expired copy for up to this long while a fresh one is fetched in the background, so nobody waits for the upstream (0s: only when the upstream allows it with stale-while-revalidate). |
lock |
true |
When many visitors ask for the same uncached page at once, send one request upstream and let the others wait for its answer. |
key |
"{scheme}://{host}{uri}" |
What identifies a cached page — e.g. "{host}{path}" to ignore query strings. |
bypass |
[] |
Conditions that skip the cache, e.g. [{ source = "cookie", name = "session", op = "exists" }]. |
disk |
— | { path = "cache/api", max_size = "1GB" }: also keep responses on disk — more room, and they survive restarts. Least recently used entries go first. |
bypass_cookies |
none (PHP: session cookies) | Visitors sending a cookie whose name starts with one of these skip the cache, e.g. ["sessionid"]. |
purge_from |
nobody (PHP: this machine) | Addresses allowed to drop pages with PURGE /path (PURGE /path*: everything under it), e.g. ["127.0.0.1", "10.0.0.0/8"]. |
It follows shared-cache rules: s-maxage / max-age / Expires decide freshness; private, no-store, no-cache, Set-Cookie and Vary: * are never stored; Vary is honoured; requests with Authorization bypass it. Responses say X-Cache: HIT, MISS, EXPIRED, STALE, UPDATING or BYPASS, conditional requests get 304, and compressed variants are built once. Purge a host's caches from its page in the panel or with POST /api/v1/hosts/{id}/purge (in a cluster, on every node) — all of it, or one page with {"path": "/about/"}, or everything under a path with {"path": "/blog/*"}.
sticky — session affinity by cookie: the first answer sets a cookie naming the upstream, and later requests go back to it while it is up.
| Key | Default | Meaning |
|---|---|---|
cookie |
"arka_upstream" |
|
ttl |
— | How long the choice lasts; unset: until the browser closes. |
canary — a canary release: a share of new visitors goes to other upstreams and stays there.
| Key | Default | Meaning |
|---|---|---|
upstreams |
— | The canary servers. |
percent |
— | Share of new visitors sent to them, 0–100. Raise it step by step. |
cookie |
"arka_canary" |
Remembers each visitor's side. |
header |
— | A request header that forces the choice — "always"/"1" or "never"/"0" — handy for testers. |
mirror — copy requests to another server to try a new version with real traffic. Its answers are ignored and never delay or change the real response.
| Key | Default | Meaning |
|---|---|---|
upstream |
— | Where copies go: "127.0.0.1:9000", "http://staging:8080"… |
percent |
100 |
Share of requests copied. |
max_body |
"64KB" |
Requests with larger (or unknown-length) bodies are not copied. |
At most 256 copies are in flight, so a slow mirror cannot pile up work.
discovery — where the upstreams come from. upstreams (if any) serve until the first lookup succeeds; after that the list follows the source, and a failed lookup keeps the last good list.
discovery = { type = "dns", name = "app.internal", port = 8080 } # A/AAAA records
discovery = { type = "dns", name = "_http._tcp.app.internal" } # SRV: ports and weights too
discovery = { type = "docker", label = "com.example.service=web", port = 8080 }
discovery = { type = "consul", service = "web", address = "http://127.0.0.1:8500" }
discovery = { type = "kubernetes", service = "web", namespace = "shop", port = "http" }
| Type | Keys |
|---|---|
dns |
name, port (for A/AAAA), resolver (a DNS server for SRV, e.g. "10.0.0.2:53"; default: the system's) |
docker |
label ("name=value" or a label name), port, network (default: the first), socket (default /var/run/docker.sock) |
consul |
service, address (default http://127.0.0.1:8500), tag, token |
kubernetes |
service, namespace (default "default"), port (name or number; default: the first), api, token_file, ca (default: the in-cluster API with the pod's service account) |
Every type takes interval (default "30s") and https (default false: reach the upstreams found over HTTPS). Only ready endpoints (Kubernetes), passing instances (Consul) and running containers (Docker) are used. The host's page in the panel shows the current list.
Recipe: Django behind Arka (the same holds for Flask, Rails, Laravel and other apps served by worker processes). Static files are served by Arka, slow visitors never hold a gunicorn worker, requests beyond the workers wait their turn, and protected downloads go through X-Accel-Redirect:
server_names = ["shop.example.com"]
listen = [":80"]
[[routes]] # `manage.py collectstatic` → STATIC_ROOT
path = "/static/"
handler = { type = "static", root = "/srv/shop/staticfiles", strip_prefix = true, cache_control = "public, max-age=3600" }
[[routes]] # private files views hand over with X-Accel-Redirect
path = "/protected/"
internal = true
handler = { type = "static", root = "/srv/shop/private", 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" }
preserve_host = true
buffering = {}
accel_redirect = true
health_check = { path = "/healthz", interval = "5s", expect_body = '"status": "ok"' }
On the Django side: STATIC_ROOT for collectstatic; SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") so HTTPS is recognised; USE_X_FORWARDED_HOST = True (safe: clients cannot forge it through Arka); CSRF_TRUSTED_ORIGINS listing the site's https:// origin; and, for private files, SENDFILE_BACKEND = "django_sendfile.backends.nginx" with SENDFILE_URL = "/protected".
type = "fastcgi" — PHP and other FastCGI applications#
Set up like a typical PHP site: existing .php files run, other files are served as they are, and everything else goes to the front controller (WordPress, Laravel, Symfony, Drupal…).
[[routes]]
path = "/"
[routes.handler]
type = "fastcgi"
address = "unix:/run/php/php8.3-fpm.sock" # or "127.0.0.1:9000"
root = "/var/www/wordpress"
front_controller = "/index.php"
max_conns = 5 # php-fpm's pm.max_children
queue = { size = 200, timeout = "30s" }
| Key | Default | Meaning |
|---|---|---|
address |
— | The FastCGI server — "127.0.0.1:9000" or "unix:/run/php/php-fpm.sock" — or a list of them sharing the load (least busy first; one that fails max_fails times within fail_timeout is skipped for fail_timeout). |
root |
— | The site's directory (scripts and static files). |
index |
["index.php", "index.html"] |
Files tried for a directory. |
extensions |
[".php"] |
Files run as scripts; others are served as they are. |
front_controller |
— | The script for requests matching no file, e.g. "/index.php". Without it they get 404. |
static_files |
true |
Serve non-script files directly (fast, cached, compressed). |
static_cache_control |
— | Cache-Control for those files, e.g. "public, max-age=2592000" (WordPress versions its CSS and JS with ?ver=). |
deny_scripts |
[] |
Path prefixes where scripts never run (403), e.g. ["/wp-content/uploads/"] — an uploaded .php file cannot be executed. |
params |
{} |
Extra parameters ($_SERVER in PHP); values are templates: { APP_ENV = "production" }. |
strip_prefix |
false |
Remove the route's prefix before looking up files. |
follow_symlinks |
false |
Run scripts and serve files through symbolic links that lead out of root (as for static files). |
connect_timeout |
"5s" |
|
timeout |
"60s" |
Time for the application to start answering. |
read_timeout |
"60s" |
Time between two reads of the answer: a script that hangs half-way is cut off (504 if nothing was sent yet). |
max_conns |
0 |
Most connections open to each server at once, busy or idle (0: no limit). Set it to php-fpm's pm.max_children — see below. |
queue |
— | { size = 100, timeout = "30s" }: requests wait for a free connection, first come first served, instead of getting 503 at once. |
keepalive |
on with max_conns |
Keep connections open between requests (about a quarter faster). Off by default without max_conns: see below. |
keepalive_timeout |
"10s" |
Close a kept connection after this long unused. |
max_fails, fail_timeout |
3, "10s" |
When there are several servers: failures that take one out of rotation, and for how long. |
health_check |
— | { interval = "10s", timeout = "3s", path = "/ping" }: probe every server in the background. With path (php-fpm's ping.path) a real FastCGI request must answer 200; without it, a connection that opens passes. |
buffering |
requests and answers | { requests, responses, memory = "256KB", max_size = "1GB" } — see below. |
cache |
— | A page cache — see below. |
accel_redirect |
false |
Follow X-Accel-Redirect: /path answers: the script decides who may download, Arka serves the file (as for the proxy). |
The usual CGI parameters are set — SCRIPT_FILENAME, SCRIPT_NAME, PATH_INFO, DOCUMENT_ROOT, REQUEST_URI, QUERY_STRING, REMOTE_ADDR, SERVER_NAME, SERVER_PORT, HTTPS and every request header as HTTP_* — HTTP_HOST over HTTP/2 and HTTP/3 too. Behind a proxy trusted by real_ip, HTTPS, REQUEST_SCHEME and SERVER_PORT describe what the visitor used (X-Forwarded-Proto, X-Forwarded-Port, Forwarded: proto=), so WordPress builds https links behind a TLS-terminating load balancer; from anyone else, X-Forwarded-*, Forwarded and X-Real-IP are dropped, so scripts that believe them (many WordPress plugins read HTTP_X_FORWARDED_FOR) cannot be fooled. Paths can never escape root, and only files with a script extension are ever executed. File lookups are remembered for 2 seconds (like nginx's open_file_cache), so a new or deleted script may take that long to be noticed.
PHP's workers. php-fpm runs a fixed number of workers (pm.max_children, 5 on Ubuntu), and gives each connection one of them — for as long as the connection stays open. Arka protects them three ways:
- Buffering (on by default). An upload is read whole — in memory, then a temporary file — before PHP is asked, so a visitor uploading slowly holds no worker. An answer is read as fast as PHP writes it: small ones (up to
buffering.memory) whole, which also gives the visitor aContent-Length; larger ones are spooled while the visitor reads at their own pace. A script that must stream (progress output, server-sent events) sendsX-Accel-Buffering: no. max_conns. Arka never has more connections open to a server than this, busy or idle, so it never queues a visitor behind php-fpm's accept queue for a worker an idle connection holds. Beyond it, requests wait in thequeue(or get503).- Kept connections only when capped. A kept connection pins a worker even while idle. Without
max_conns, kept connections could hold every worker while new ones wait — sokeepaliveis off by default then, and on (faster) oncemax_connsis set. A connection that finishes while requests are queued goes straight to the one that has waited longest on the same worker thread; when only requests on other threads wait, it is closed, so the php-fpm worker it held takes their new connection — and one kept idle by another worker thread is closed to make room. A request that arrives while others wait queues behind them rather than taking the next connection first.
Routes and hosts naming the same servers with the same limits share one set of connections, so max_conns holds for the php-fpm pool as a whole. The servers' health, requests, failures and limits are shown with the proxy upstreams in the panel, where they can be drained or disabled.
The page cache. cache = {…} takes the proxy cache's settings — memory and disk, default_ttl, stale_while_revalidate, stale_if_error, lock, key, bypass — and serves pages without running PHP:
cache = { default_ttl = "10m", stale_while_revalidate = "1m", stale_if_error = "1h" }
- WordPress sends no
Cache-Controlfor public pages, sodefault_ttldecides how long they are kept; pages that sayno-cache,privateorno-store(WordPress's admin, carts, checkout) and answers setting a cookie are never kept. - Visitors sending a cookie whose name starts with one of
bypass_cookiesskip the cache entirely — they see their own pages, and those pages are never kept for others. The default list covers WordPress (wordpress_logged_in_,wordpress_sec_,wp-postpass_,comment_author_), WooCommerce and Easy Digital Downloads carts, Drupal (SESS,SSESS), Laravel and PHP sessions (PHPSESSID). The answer'sX-Cachesays what happened:HIT,MISS,EXPIRED,UPDATING,STALEorBYPASS. - When PHP or its database fails —
500,502,503,504, or no answer — an expired copy younger thanstale_if_erroris served instead. PURGE /pathdrops a page in every variant (PURGE /blog/*: everything under/blog/), from the addresses inpurge_from(default: this machine). The panel andPOST /api/v1/hosts/{id}/purgewith{"path": "/blog/*"}do the same from anywhere; without a path, everything goes. An application, or a cache-purging plugin for it, sendsPURGEwhen content changes — the same request a Varnish or nginx cache-purge setup takes.
Example: WordPress#
A WordPress site on php-fpm, with max_conns set to php-fpm's pm.max_children:
[[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
queue = { size = 200, timeout = "30s" }
static_cache_control = "public, max-age=2592000"
deny_scripts = ["/wp-content/uploads/"]
cache = { default_ttl = "10m", stale_while_revalidate = "1m", stale_if_error = "1h" }
accel_redirect = true
# WooCommerce's protected downloads (Downloads: X-Accel-Redirect) — never
# reachable directly.
[[routes]]
path = "/wp-content/uploads/woocommerce_uploads/"
internal = true
handler = { type = "static", root = "/var/www/wordpress/wp-content/uploads/woocommerce_uploads", strip_prefix = true }
# Password guessing on the login page and XML-RPC is slowed down.
[[routes]]
path = "^/(wp-login|xmlrpc)\\.php$"
match = "regex"
rate_limit = { requests_per_second = 0.5, burst = 10 }
[routes.handler]
type = "fastcgi"
address = "unix:/run/php/php8.3-fpm.sock"
root = "/var/www/wordpress"
front_controller = "/index.php"
max_conns = 5
queue = { size = 200, timeout = "30s" }
Behind Cloudflare or a load balancer, add real_ip so WordPress sees the visitor's address and https. The panel's PHP site template writes the first route; the others are WordPress specifics you add yourself.
type = "webdav" — a network drive#
A WebDAV share (class 1 and 2, with locks) that Windows Explorer, macOS Finder, GNOME Files, KDE Dolphin, rclone and Cyberduck can mount.
[[routes]]
path = "/files/"
handler = { type = "webdav", root = "/srv/share", strip_prefix = true }
auth = { type = "basic", users = ["alice:$2b$10$…"] }
| Key | Default | Meaning |
|---|---|---|
root |
— | The shared directory. |
strip_prefix |
false |
Remove the route's prefix when mapping to files. |
read_only |
false |
Browse and download only: no uploads, moves or deletes. |
show_hidden |
false |
Show and allow names starting with a dot. |
follow_symlinks |
false |
Read and change files through symbolic links that lead out of root. Off, such links answer 404 (reads) or 403 (writes), and copies of a folder leave them out. |
Put auth on the route unless the files are meant for everyone — arka check warns otherwise. Downloads use the static file machinery (ranges, ETag, compression).
type = "script"#
Answer with a Rhai script's handle(req): handler = { type = "script", file = "scripts/hello.rhai" }. Optional max_operations (default 200000) caps the work per request; a call also stops after 100 ms, whatever its operations.
type = "redirect"#
| Key | Default | Meaning |
|---|---|---|
to |
— | A template, e.g. "https://example.com{uri}" or "/posts/{1}". |
code |
301 |
301, 302, 303, 307 or 308. |
type = "respond"#
| Key | Default |
|---|---|
status |
200 |
body |
"" |
content_type |
"text/plain; charset=utf-8" |
Security#
[auth]#
Authentication for the whole host, or per route with auth = {…}. A route can opt out with auth = { type = "none" }. The signed-in user is available as {user} in templates and logs.
Basic — users with htpasswd-style hashes (bcrypt $2y$/$2b$, $apr1$, {SHA}; never plain text):
[auth]
type = "basic"
realm = "Staff"
users = ["alice:$2b$10$…"] # from `arka passwd alice`
users_file = "secrets/htpasswd" # optional, an htpasswd file
bcrypt checks run off the event loop and successful ones are remembered for five minutes, so only the first request of a session pays for them. Ten failures from one address lock it out for a while.
OpenID Connect — sign in with Google, Microsoft Entra ID, Keycloak, Authentik, Okta, GitLab, Auth0…:
[auth]
type = "oidc"
issuer = "https://accounts.google.com"
client_id = "1234.apps.googleusercontent.com"
client_secret = "${GOOGLE_CLIENT_SECRET}"
allowed_users = ["@example.com", "contractor@gmail.com"]
claims_to_headers = { email = "X-User-Email" }
| Key | Default | Meaning |
|---|---|---|
issuer |
— | The provider's issuer URL; everything else is discovered from it. |
client_id, client_secret |
— | From the provider. Leave out the secret for a public client (PKCE alone proves the sign-in). |
scopes |
["openid", "email", "profile"] |
|
redirect_path |
"/.arka/oidc/callback" |
Register https://<your site><redirect_path> with the provider. |
logout_path |
"/.arka/oidc/logout" |
Visiting it signs out. |
allowed_users |
[] |
E-mail addresses, or "@example.com" for a whole domain. Empty lets every signed-in user in. |
required_claims |
{} |
Claims that must have these values, e.g. { groups = "admins" } (list claims must contain the value). |
claims_to_headers |
{} |
Claims copied onto the request sent upstream. |
session_ttl |
"12h" |
How long a sign-in lasts. |
cookie |
"arka_session" |
Browsers are sent to the provider and back (authorization code flow with PKCE, state and nonce); API clients without a session get 401. The ID token's signature is checked against the provider's published keys. The session lives in an encrypted cookie (AES-GCM, key in <state_dir>/session.key, shared across a cluster), so nothing is stored on the server.
LDAP — the browser asks for a user name and password, which Arka checks against OpenLDAP, Active Directory, FreeIPA…:
[auth]
type = "ldap"
url = "ldaps://ldap.example.com"
bind_dn = "uid={user},ou=people,dc=example,dc=com" # direct bind — or search first:
# search_base = "dc=example,dc=com"
# search_filter = "(sAMAccountName={user})"
# service_dn = "cn=reader,dc=example,dc=com"
# service_password = "${LDAP_PASSWORD}"
required_group = "cn=staff,ou=groups,dc=example,dc=com"
| Key | Default | Meaning |
|---|---|---|
url |
— | "ldap://…" or "ldaps://…:636". |
bind_dn |
— | Direct bind: the user's DN as a template with {user}. |
search_base, search_filter |
— | Or search for the user first. |
service_dn, service_password |
— | The account that searches (anonymous when unset). |
required_group |
— | Only members of this group (its DN) get in. |
starttls |
false |
Upgrade ldap:// connections with StartTLS. |
ca |
— | A CA certificate (PEM) to trust for ldaps:// and StartTLS. |
realm |
"Restricted" |
|
cache_ttl |
"5m" |
Remember a successful sign-in this long. |
timeout |
"5s" |
User names are escaped before they reach a DN or filter, so they cannot inject LDAP syntax. Repeated failures lock an address out for a while.
Forward — ask an auth service on every request (nginx auth_request, Traefik forwardAuth; works with Authelia, Authentik, oauth2-proxy…):
[auth]
type = "forward"
url = "http://127.0.0.1:9091/api/verify"
copy_headers = ["Remote-User", "Remote-Groups"]
timeout = "5s"
The service receives the client's headers plus X-Forwarded-Method, -Proto, -Host, -Uri, -For, X-Original-URL and X-Request-Id. A 2xx lets the request through, with copy_headers taken from the answer (client-sent copies are removed first, so they cannot be forged). Any other answer — a 401, or a 302 to a sign-in page — goes back to the client.
JWT — require a valid JSON Web Token:
[auth]
type = "jwt"
secret = "a long random secret" # HS256/384/512 — or:
# public_key = "keys/jwt.pem" # RS256/384/512, PS256/384/512, ES256, ES384, EdDSA
issuer = "https://id.example.com" # optional
audience = "api" # optional
cookie = "session" # optional: also read the token from this cookie
claims_to_headers = { sub = "X-User", roles = "X-Roles" }
leeway = "60s"
# require_exp = false # accept tokens without `exp` (they never expire)
The key decides which algorithms are accepted, so "alg": "none" and algorithm-confusion attacks are rejected. A token must carry exp — one without it would work forever — unless require_exp = false; nbf is checked when present.
[waf]#
A web application firewall: requests that look like attacks are refused before they reach the site. It runs one of two rule sets: Arka's own — inspired by the OWASP Core Rule Set, tuned to stay quiet on ordinary traffic and to cost next to nothing — or the OWASP Core Rule Set itself, complete.
Protection level. How much protection a site gets is up to you, in one word: level picks the rule set, how hard it looks (paranoia), which requests it inspects (inspect) and what becomes of a body too large to inspect (oversize). Any of those set as well replaces what the level sets — level = "strict" with inspect = "apps" is level 2 of the Core Rule Set on application requests only.
level |
Rules | paranoia |
Inspects | Larger bodies | Cost and fit |
|---|---|---|---|---|---|
"basic" |
Arka's own | 1 | every request | start inspected | About 1 µs a request; rarely refuses an ordinary visitor. What a [waf] without a level gets — so a configuration written before levels existed behaves as it always did. |
"standard" |
OWASP Core Rule Set | 1 | requests that can reach an application | refused | The recommended level, and what the control panel turns on. Static files and pages a cache answers — where an attack has nothing to run — are left alone, so they cost nothing extra; requests that reach the application pay about 0.1–0.2 ms. |
"strict" |
OWASP Core Rule Set | 2 | every request | refused | Catches more; most applications need a few exceptions, and static files pay the rule set's cost too. |
"maximum" |
OWASP Core Rule Set | 3 | every request | refused | The widest coverage: ordinary requests will be refused until you add exceptions — start with mode = "detect". Level 4 is there too, with paranoia = 4. |
Whatever the level, mode decides whether a match refuses the request or is only logged, and exceptions, custom rules, bodies and thresholds work the same.
What ordinary clients send gets through. At standard and strict, Arka starts the Core Rule Set from settings that let real traffic pass, where the stock ones refuse it: the Expect header (curl, PHP's curl on bodies over 1 KB — payment and shop webhooks — and the AWS SDKs send it), text/plain bodies (navigator.sendBeacon, analytics) and application/octet-stream (upload APIs), PUT, PATCH, DELETE and WebDAV's methods, and GraphQL queries ($first, the usual paging variable, is also a MongoDB operator: rules 942290 and 942430 are spared in a JSON body's query field only). A test sends requests real clients make — browsers over HTTP/1.1 and HTTP/2, forms, JSON and GraphQL APIs, beacons, uploads, webhooks, an S3 client, crawlers, a mobile app — through every level: standard must let them all through, strict every one a browser makes. maximum is the stock rule set, and settings of your own in crs win over all of these.
Learn first. With learn_until = "2026-10-30T07:00:00Z" (UTC), the firewall only logs what it would refuse until then, and blocks by itself afterwards — no reload. The control panel sets it a day ahead when it turns the firewall on: false positives show up on the Firewall page before any visitor is refused, and each one there has an Allow here button, which adds an exception for the rules it matched on its path (and below) to the host file — checked, saved and audited like any change.
Bans. Only attack rules earn ban points: a request refused for its protocol alone — a missing header, an unusual method or content type (the Core Rule Set's 911 and 920 rules, Arka's protocol.*) — is refused, but its client is not banned for it. A host whose firewall suddenly refuses many requests (50 in five minutes, a tenth of its traffic or more), and a burst of automatic bans (5 in ten minutes), raise a firewall alert.
Anomaly scoring. Every rule that matches adds its score to the request's — 5 for a near-certain sign of an attack, 4, 3 or 2 for weaker ones — and the request is refused once the total reaches threshold (5). One strong sign is enough; weak ones need company: a comment containing a backtick (3 points) passes, a backtick and a quote followed by -- (6) does not.
Sensitivity. paranoia sets how hard the rules look. Level 1 runs only the rules for near-certain attacks; levels 2 to 4 add rules that catch more — and refuse more ordinary requests by mistake (SQL function calls, hex literals, any HTML tag, requests without a User-Agent…). To try a level before turning it on, set log_paranoia to it: its rules run too, but what they find is only logged, on the Firewall page.
Level 4 reads HTTP strictly, as the Core Rule Set's level 4 does. On their own, a method other than GET, HEAD, POST, PUT, PATCH, DELETE or OPTIONS (protocol.unusual-method) and a body whose Content-Type is not a form, multipart, JSON or XML — or has none — (protocol.content-type) are refused. Weaker signs only add up: more than 64 parameters (protocol.many-args), a parameter or cookie over 4 KB (protocol.long-value), a number followed by an SQL keyword such as 5 order by (sqli.numeric-keyword), and four or more SQL special characters in one value (sqli.special-chars). A WebDAV share or an upload endpoint needs an exception at this level: rules = ["protocol.unusual-method", "protocol.content-type"] for its path.
Seeing through disguises. Values are decoded before the rules see them, the way the application would: percent escapes up to three layers deep (%253C), %u escapes, overlong UTF-8 (%c0%ae), HTML entities (<, <, :), JavaScript escapes (\u003c), full-width characters (<), and SQL comments (UN/**/ION, /*!50000SELECT*/). SQL and shell rules accept any control character where a space would go, as SQL Server does.
Fast. One pass over each value with a literal prefilter (Aho-Corasick) finds the few rules that could match, and only those run their expressions; ordinary values run none. Hosts with the same settings share one compiled rule set.
[waf]
level = "standard" # the Core Rule Set on application requests
mode = "detect" # log only, while you watch for false positives
log_paranoia = 2 # also log what level 2 would refuse
# A rich-text editor posts HTML: spare the XSS rules there, for those fields only.
[[waf.exceptions]]
rules = ["xss"]
paths = ["/admin/posts/"]
args = ["content", "body*"]
[[waf.custom]]
name = "no-old-api"
target = "path"
pattern = "^/api/v1/"
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
|
mode |
"block" |
"block" (refuse with 403) or "detect" (log only). |
learn_until |
— | Only log until this moment (UTC, "2026-10-30T07:00:00Z"), then block. |
level |
"basic" |
"basic", "standard", "strict" or "maximum" — see protection level above. |
ruleset |
the level's | "arka" (the built-in rules below) or "crs" (the OWASP Core Rule Set). |
inspect |
the level's | "all" requests, or only those that can reach an application ("apps"): static files, redirects, fixed answers, paths a PHP site has no script for, and pages a cache answers go uninspected. With "crs" that is most of a site's requests at a tenth of the cost (a small static page: 18k → 183k requests a second here). A request let through because the cache holds its page is answered from that very copy — it never reaches the application uninspected, even if the copy expires meanwhile. Probes of static paths then earn no ban points. |
crs |
{} |
With ruleset = "crs": its settings. |
inspect_response |
false |
With ruleset = "crs": also check what the site answers, up to max_response_body ("512KB"). |
rules |
all groups | "sqli", "xss", "traversal", "rce", "probes", "scanners", "protocol". |
paranoia |
the level's | 1–4: how hard the rules look. |
log_paranoia |
0 |
A higher level whose extra rules only log what they find. 0 runs nothing extra. |
threshold |
5 |
The score at which a request is refused. |
inspect_body |
true |
Also inspect request bodies: forms, JSON, XML, text and uploads (field names and values, upload filenames). Bodies larger than 64 KB wait in the spool (state_dir/tmp, within server.buffer_disk_max) and reach the site only once inspected. |
max_body |
"1MB" |
The largest form, JSON, XML or text body inspected whole. Uploads (multipart/form-data) are inspected whole whatever their size — every part's headers and every field, not the files' bytes — as long as their fields fit in it; files of any size up to the host's limits.max_body_size are held until then. Other bodies (images, archives) have their first 64 KB inspected by the Core Rule Set. |
oversize |
the level's | A body larger than max_body: "refuse" it with 413 — nothing reaches the site uninspected, as ModSecurity does — or "inspect_start": its first max_body is inspected and the rest let through, so an attack placed after that much padding is not seen. A refusal shows on the Firewall page as protocol.body-too-large; in detect mode it is only logged. |
exclude_paths |
[] |
Path prefixes never inspected. Prefer an exception when one rule is the problem. |
exceptions |
[] |
Rules switched off for some paths or fields only — see below. |
custom |
[] |
Your own rules: name, target ("path", "query", "args", "body", "headers", "header:Name", "user_agent", "cookies", "method"), pattern (a regular expression), action ("block" or "log") and score: unset, a block rule refuses on its own; set, it adds that many points instead. |
| Group | Looks at | Catches |
|---|---|---|
sqli |
parameters, form/JSON fields, cookies | union select, ' or 1=1, sleep(…), /*!…*/ |
xss |
parameters, form/JSON fields | <script>, onerror=, javascript: (tabs and all), <iframe> |
traversal |
path, parameters | ../, /etc/passwd, \windows\, file://, null bytes |
rce |
parameters, fields, User-Agent, Referer | ; cat, $(…), ${jndi:…}, <?php, {{config}}, reverse shells |
probes |
path, parameters | /.env, /.git/, wp-config.php.bak, 169.254.169.254 |
scanners |
User-Agent | sqlmap, nikto, nuclei… |
protocol |
method, parameters, User-Agent | TRACE/TRACK, floods of parameters |
Exceptions are the precise fix for a false positive. Each spares some rules — rule ids (sqli.tautology), groups (xss), your own rules (custom.<name>) or all of them (*) — on some path prefixes (paths; every path when empty) and for some parameters, form fields, JSON keys or cookies (args; a trailing * matches a prefix; every field when empty). An exception that names args never spares the path or the headers. The Firewall page shows which rules matched each request.
Every built-in rule, with its score and level
| Rule | Catches | Score | Level |
|---|---|---|---|
sqli.union-select |
union select | 5 | 1 |
sqli.select-from |
select from | 5 | 1 |
sqli.tautology |
tautology | 5 | 1 |
sqli.numeric-tautology |
numeric tautology | 5 | 1 |
sqli.comment |
comment after a quote | 3 | 1 |
sqli.time-delay |
time delay | 5 | 1 |
sqli.stacked-query |
stacked query | 5 | 1 |
sqli.schema-probe |
schema probe | 5 | 1 |
sqli.file-access |
file access | 5 | 1 |
sqli.xp-cmdshell |
xp_cmdshell | 5 | 1 |
sqli.versioned-comment |
MySQL versioned comment | 5 | 1 |
sqli.functions |
SQL function call | 3 | 2 |
sqli.hex-literal |
hex literal | 3 | 2 |
sqli.statement |
SQL statement | 3 | 2 |
sqli.quote-operator |
operator after a quote | 2 | 3 |
xss.script-tag |
script tag | 5 | 1 |
xss.event-handler |
event handler | 5 | 1 |
xss.javascript-url |
javascript URL | 5 | 1 |
xss.embedding-tag |
embedding tag | 5 | 1 |
xss.markup-tag |
SVG, MathML or form tag | 3 | 1 |
xss.dom-sink |
DOM sink | 5 | 1 |
xss.css-expression |
CSS expression | 5 | 1 |
xss.data-url |
HTML data URL | 5 | 1 |
xss.dialog-call |
dialog call | 3 | 1 |
xss.html-tag |
HTML tag | 2 | 2 |
traversal.dot-dot |
dot dot | 5 | 1 |
traversal.system-file |
system file | 5 | 1 |
traversal.windows-path |
Windows system path | 5 | 1 |
traversal.null-byte |
null byte | 5 | 1 |
traversal.file-url |
file URL | 4 | 1 |
rce.shell-command |
shell command | 5 | 1 |
rce.subshell |
command substitution | 5 | 1 |
rce.backticks |
backtick command | 3 | 1 |
rce.log4shell |
log4shell | 5 | 1 |
rce.expression-injection |
expression injection | 5 | 1 |
rce.shellshock |
shellshock | 5 | 1 |
rce.php-code |
PHP code | 5 | 1 |
rce.template-injection |
template injection | 5 | 1 |
rce.reverse-shell |
reverse shell | 5 | 1 |
rce.download |
download command | 3 | 1 |
probes.secret-file |
secret file | 5 | 1 |
probes.config-backup |
configuration backup | 5 | 1 |
probes.cloud-metadata |
cloud metadata address | 5 | 1 |
probes.admin-tool |
admin tool | 3 | 2 |
probes.backup-archive |
backup archive | 3 | 2 |
scanners.security-scanner |
security scanner | 5 | 1 |
scanners.scripted-client |
scripted client | 2 | 3 |
protocol.method |
TRACE, TRACK or DEBUG method | 5 | 1 |
protocol.too-many-args |
too many parameters | 5 | 1 |
protocol.no-user-agent |
no User-Agent | 2 | 2 |
Every finding is logged with the rules that matched, the score, the matched part and the request id, counted in arka_http_events_total{event="waf_blocked"}, and listed on the panel's Firewall page. Refused requests also count towards a ban.
The OWASP Core Rule Set#
ruleset = "crs" — or any level from "standard" up — runs the OWASP Core Rule Set 4.30 — every request and response rule, its phrase lists, paranoia levels and anomaly scoring — the industry-standard rule set of ModSecurity and Coraza. It is built into Arka (nothing to download or keep up to date by hand: a new Arka brings the new rules) and run by Arka's own engine, which passes all 5,194 of the CRS project's own regression tests that are about the rules rather than the web server.
[waf]
ruleset = "crs"
mode = "detect" # watch the Firewall page for a while first
paranoia = 1 # the CRS blocking paranoia level
log_paranoia = 2 # its detection level: logged, not refused
threshold = 5 # its inbound anomaly score threshold
inspect_response = true # leaked SQL errors, stack traces, source code, web shells
crs = { allowed_methods = "GET HEAD POST OPTIONS PUT DELETE", arg_length = "400" }
# WordPress posts HTML from its editor.
[[waf.exceptions]]
rules = ["941100-941999"]
paths = ["/wp-admin/post.php"]
args = ["content"]
- Settings. The usual keys apply with the rule set's meaning:
paranoiais its blocking level,log_paranoiaits detection level,thresholdits inbound threshold, andrulesturns its groups on and off (sqli= 942–943,xss= 941,traversal= 930–931 —probesare part of it —,rce= 932–934 and 944,scanners= 913,protocol= 911 and 920–922).crssets any of thetx.*variables of CRS'scrs-setup.conf, without the prefix:allowed_methods,allowed_request_content_type,restricted_extensions,max_num_args,arg_length,total_arg_length,max_file_size,combined_file_sizes,outbound_anomaly_score_threshold… - Exceptions name CRS rule ids (
942100), ranges (942100-942199), groups (sqli) or*, for somepathsandargs(the argument, its name, and cookies of that name), like the built-in ones. - Responses. With
inspect_response, text answers (HTML, JSON, XML, plain text) up tomax_response_bodyare held back and checked by the outbound rules (950–956), and replaced by a403page when they leak; larger and compressed-by-the-site answers go out unchecked. Answers that are held back cannot stream (server-sent events arrive at once), so leave it off for such routes. - Cost. About 0.2 ms of CPU for an ordinary request at paranoia 1 — some fifty times Arka's own rules, still a small part of what an application spends on a request — and, once per process however many hosts use it, about 40 MB of memory and a third of a second to compile the rules when the first host needs them. Regular expressions run on a matcher that never backtracks, so no rule can be turned into a denial of service, and a literal prefilter skips the expressions a value cannot match.
- False positives. The rule set catches much more than the built-in rules, and applications that post code, HTML or SQL-looking text need exceptions. Start with
mode = "detect": the Firewall page lists the rule ids to put in[[waf.exceptions]].
[access]#
[access]
allow = ["10.0.0.0/8", "192.168.1.15"] # when non-empty, everyone else gets 403
deny = ["10.0.0.66"] # checked first
[geo]#
By country (ISO codes), with a [geoip] database:
[geo]
allow = ["IN", "US", "GB"] # when non-empty, every other country gets 403
deny = [] # checked first
[challenge]#
A bot check before the site. A visitor without a pass gets a small page that solves a proof-of-work puzzle in the background — finding a number whose SHA-256, with the page's token, starts with difficulty zero bits; a fraction of a second on a phone — and then a pass for pass_for, in a cookie. Clients that do not run JavaScript, which is most scrapers and AI crawlers, never get past it; the site never sees them, nor the pass cookie. Nothing is kept per visitor: the puzzle and the pass are sealed with the installation's session.key (so every cluster node accepts them) and bound to the browser's User-Agent.
[challenge]
mode = "suspicious" # or "always": every visitor, once per pass
difficulty = 18 # zero bits; each one doubles the work (10–26)
pass_for = "1d"
exempt = ["/api/", "/webhooks/", "/feed.xml"]
verified_crawlers = true # let real search engines in
suspiciouschecks only visitors that look like bots: no User-Agent, a scripting tool (curl,python-requests,Go-http-client, headless Chrome…), a crawler gathering pages for AI models (GPTBot, ClaudeBot, CCBot, Bytespider, PerplexityBot…), a "browser" that sends noAccept-Language, or an address already collecting ban points.alwayschecks everyone.- Never checked: anything but
GETandHEAD(a form cannot be sent again after the page),/robots.txt,/favicon.ico,/.well-known/, theexemptpath prefixes, and addresses on the bansneverlist — this machine (loopback) among them by default, so the check does not show when you test from the server itself. Put API clients, webhooks and feed readers underexempt: they do not run JavaScript. - Search engines (Googlebot, Bingbot, Applebot, YandexBot, Baiduspider) are let in once DNS confirms their address — its reverse name is theirs and resolves back to it, cached for an hour; one only claiming to be Googlebot is checked like anyone else.
- A wrong or forged answer is refused with the puzzle again, shown on the Security page as Bot check, and earns
points.challenge(2) towards a ban. Checks are counted per host aschallenged,challenge_passedandchallenge_failed(the panel's host page andarka_http_events_total). - The page brings its own Content-Security-Policy and loads nothing from elsewhere; it works over plain HTTP too.
[rate_limit]#
Per client, with a smooth token bucket (GCRA):
[rate_limit]
requests_per_second = 50
burst = 100
key = "{header.X-API-Key}" # optional: who counts as one client (default: the client IP)
Clients over the limit get 429 with Retry-After. An empty key result falls back to the client IP. In a cluster with share_rate_limits, the limit holds across all nodes.
[connections]#
Simultaneous requests per client (nginx limit_conn) — downloads, long polls, uploads:
[connections]
per_client = 4
key = "{client_ip}" # optional
Requests over the limit get 429.
Signed links#
signed = {…} on a route lets only signed, unexpired links through (nginx secure_link) — for downloads you hand out:
[[routes]]
path = "/downloads/"
handler = { type = "static", root = "private/downloads", strip_prefix = true }
signed = { secret = "${DOWNLOAD_SECRET}" }
Make links with arka sign-url <host> /downloads/report.pdf --expires 7d, or with Make a link in the route's settings in the panel. A link looks like /downloads/report.pdf?expires=1767225600&signature=…; the signature is HMAC-SHA256 over "<expires>:<path>", base64url without padding — easy to produce from any language. Optional expires_param and signature_param rename the parameters. Expired links get 410, wrong ones 403.
[limits]#
| Key | Default |
|---|---|
max_body_size |
"16MB" ("0" disables the limit) |
body_timeout |
"60s": the longest a client may pause while sending a request body (nginx client_body_timeout). It then gets 408 instead of holding the connection, whichever handler reads the body — and the firewall, which reads it first. "0s" waits for ever. |
[real_ip]#
Behind a load balancer or CDN, believe the client address it reports — but only from the proxies you list:
[real_ip]
from = ["10.0.0.0/8", "173.245.48.0/20"]
header = "X-Forwarded-For" # or "X-Real-IP", "CF-Connecting-IP", "True-Client-IP"
X-Forwarded-For is read right to left, skipping trusted proxies. The result is used by IP and country rules, rate limits, logs and upstream headers. For load balancers that speak the PROXY protocol instead, see proxy_protocol in [server].
[cors]#
[cors]
allow_origins = ["https://app.example.com", "https://*.example.com"] # or ["*"]
allow_methods = ["GET", "POST", "PUT", "DELETE"]
allow_headers = [] # empty: whatever the preflight asks for
expose_headers = ["X-Total-Count"]
allow_credentials = false
max_age = "10m"
Preflight requests are answered by Arka and never reach the handler. ["*"] cannot be combined with allow_credentials. An empty allow_origins turns CORS off (useful on a route).
Content#
[[rewrites]]#
Rewrites the path before routes are matched (nginx rewrite); the client never sees it. The first matching rule applies.
[[rewrites]]
from = "^/blog/(\\d+)/(.*)$"
to = "/posts/{1}-{2}" # a `?` in `to` replaces the query string
Access logs record the original request.
[[variables]]#
Values computed from each request (nginx map), used as {var.name} in templates and in route conditions:
[[variables]]
name = "device"
source = "{header.User-Agent}"
cases = [
{ match = "~*iphone|android", value = "mobile" },
{ match = "~*ipad|tablet", value = "tablet" },
]
default = "desktop"
match is exact text, or a regular expression written ~pattern (~*pattern ignores case). The first matching case wins; without cases, the variable is simply the source.
[headers]#
[headers]
security = true # the recommended security headers (see below)
set = { "X-Powered-By" = "Arka" }
remove = ["server"]
security = true adds these to every response that does not already set them: X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, Cross-Origin-Opener-Policy: same-origin, and a Permissions-Policy that turns off geolocation, microphone, camera, payment and USB.
| Key | Default | Meaning |
|---|---|---|
security |
false |
The recommended security headers above. |
content_security_policy |
— | A Content-Security-Policy for every response that lacks one — the strongest defence against cross-site scripting, but specific to your pages, so never added by default. |
csp_report_only |
false |
Send the policy as Content-Security-Policy-Report-Only: the browser reports what would be blocked without enforcing it, so you can roll a policy out and tune it before it breaks a page. |
[headers]
security = true
content_security_policy = "default-src 'self'; img-src 'self' data:; object-src 'none'"
csp_report_only = true # watch the reports, then flip this off to enforce
[compression]#
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
|
zstd |
true |
Zstandard: as small as Brotli at a fraction of the CPU (Chrome 123+, Firefox 126+). |
brotli |
true |
|
gzip |
true |
|
min_size |
"512B" |
Smaller responses are sent uncompressed. |
Of the encodings the client accepts, files compressed once and kept — static files, cached pages — use Brotli first (the smallest), then zstd, then gzip. Answers compressed anew for every request — apps, proxies — use zstd first: at the levels fast enough for that it is as small as Brotli for less CPU (a 44 KB PHP page: 2.6 KB against 2.8 KB, and 0.3 ms sooner). Browsers without zstd (Safari) get Brotli.
[bandwidth]#
The speed each response is sent at (nginx limit_rate), after an optional free start:
[bandwidth]
rate = "500KB" # bytes per second
after = "2MB" # sent at full speed first (video players buffer quickly)
body_replace#
Text replaced in HTML, CSS, JavaScript, JSON, XML and plain-text responses (nginx sub_filter), on a route:
[[routes]]
path = "/"
handler = { type = "proxy", upstreams = ["127.0.0.1:3000"] }
body_replace = [
{ from = "http://legacy.internal", to = "https://example.com" },
{ from = "v(\\d+)\\.cdn", to = "cdn$1", regex = true },
]
[error_pages]#
[error_pages]
404 = "www/errors/404.html"
502 = "www/errors/down.html"
[log]#
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
|
path |
<logs_dir>/<id>.access.log |
|
format |
"combined" |
"combined", "common", "json" (adds request_id, user, upstream, upstream_ms, upstream_addr, upstream_status, upstream_connect_ms, upstream_response_ms, upstream_tries (when more than one), cache, route, country, trace_id) or "custom". |
template |
— | For "custom": the line, with fields in braces (below). |
file |
true |
Write the log file — turn off to only send lines to syslog. |
syslog |
— | Also send every line to syslog: { address = "udp://10.0.0.5:514", facility = "local7", tag = "arka" }. |
Custom lines — template = "{client_ip} {method} {uri} {status} {duration_ms}ms {header.X-Api-Key}". Fields: client_ip, time (as in the combined format), time_iso, timestamp, method, uri, path, query, protocol, status, bytes, duration (seconds), duration_ms, referer, user_agent, request_id, user, upstream, upstream_ms, cache, host, route, country, trace_id, and header.Name for any request header. For proxied requests, like nginx's $upstream_* variables: upstream_addr (every address tried, ,-separated), upstream_status (every attempt's status — 502/504 for attempts that got no answer), upstream_connect_ms (0 over a reused connection), upstream_header_ms (same as upstream_ms: time to the response headers), upstream_response_ms (time to the upstream's last byte) and upstream_tries.
Syslog — address is "unix:/dev/log" (this machine's syslog, the default), "udp://host:514" or "tcp://host:601" (RFC 5424 with octet counting). facility is local0…local7 (default local7), user, daemon…; tag is the program name (default arka).
Logs are written off the request path: each worker gathers its records and a writer thread collects them every 25 ms (at once when a worker has 256 waiting), so logging costs a request no more than appending to a list. Lines reach the file within about a second. If the disk cannot keep up, records are dropped and counted (arka_log_records_dropped_total) rather than slowing requests down; a failing disk is reported at most every 10 seconds per file. Arka rotates the files itself (see [logs]); with rotate = false, arka reopen (or SIGUSR1) reopens them after another tool has.
Streams#
streams/<id>.toml proxies TCP or UDP — databases, mail, MQTT, DNS, game servers — and HTTPS passed through untouched, chosen by the name in its handshake (nginx's stream module):
# streams/postgres.toml
listen = [":5432"]
upstreams = ["10.0.0.5:5432", "10.0.0.6:5432 backup"]
max_connections_per_ip = 20
# streams/edge.toml — TLS passthrough by name, no certificates here
listen = [":443"]
upstreams = ["10.0.0.9:443"] # names matching no route
[[sni_routes]]
names = ["git.example.com"]
upstreams = ["10.0.0.20:443"]
[[sni_routes]]
names = ["*.k8s.example.com"]
upstreams = ["10.0.0.30:443", "10.0.0.31:443"]
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
|
description |
— | Shown in the panel. |
protocol |
"tcp" |
"tcp" or "udp". |
listen |
— | Addresses, e.g. [":5432"]. |
upstreams |
[] |
"host:port", optionally followed by weight=N and/or backup. |
strategy |
"round_robin" |
As for the proxy (hash hashes the client address). |
sni_routes |
[] |
TCP: route TLS connections by server name without decrypting them. Unmatched names go to upstreams. |
tls |
— | TCP: decrypt TLS here and forward plain TCP — { cert = "…", key = "…" } or { self_signed = true }. |
proxy_protocol |
— | TCP: tell the upstreams the client's address with a PROXY protocol header, "v1" or "v2". |
connect_timeout |
"5s" |
Includes the name lookup; host names are remembered as for the proxy. |
idle_timeout |
"10m" |
Close a connection (forget a UDP session) after this long without traffic. |
access |
— | { allow = […], deny = […] }, like [access]. |
max_connections_per_ip |
0 |
0 = unlimited. |
max_fails, fail_timeout |
3, "10s" |
Passive health checks. |
A stream and a host cannot share an address. The Streams page shows connections, bytes and upstream health live.
Snippets#
Settings several hosts share — security headers, a WAF setup, an auth service — go in snippets/*.toml and are pulled in with include:
# snippets/hardening.toml
[headers]
security = true
remove = ["server"]
[waf]
mode = "block"
[rate_limit]
requests_per_second = 20
burst = 40
# hosts/shop.toml
include = ["snippets/hardening.toml"] # or ["snippets/*.toml"]
server_names = ["shop.example.com"]
listen = [":80"]
[rate_limit]
requests_per_second = 100 # this file wins
The host file wins: a setting in both keeps the host's value (and earlier snippets win over later ones). Sections are merged key by key, and lists of tables — [[routes]], [[variables]], [[rewrites]] — are appended after the host's own. Snippets may include other snippets, a few levels deep. Changing a snippet rebuilds every host that includes it. Edit them under Configuration → Snippets; the panel lists which hosts use each one.
Environment variables#
Any string value in any file may use ${NAME}, ${NAME:-default} (used when the variable is unset or empty) and $${ for a literal ${:
[auth]
type = "oidc"
client_secret = "${OIDC_SECRET}"
[[routes]]
path = "/api/"
handler = { type = "proxy", upstreams = ["${API_HOST:-127.0.0.1}:${API_PORT:-3000}"] }
Only string values are expanded — not comments or keys — and the value is escaped for the string it lands in, so a variable can never break the file's syntax. A variable that is unset and has no default is an error naming it. Saving from the panel keeps the ${…} reference unless that setting is changed.
Names starting with ARKA_ belong to Arka (ARKA_ADMIN_TOKEN is the panel's owner token): arka.toml may use them, but host, stream and snippet files — which the panel's editors can write — may not, or a host could serve one as a page.
Secrets from systemd. ${credential:NAME} reads a systemd credential instead — the file NAME in $CREDENTIALS_DIRECTORY, without its final line break — so a secret sits neither in arka.toml nor in the environment, which every process of the service can read. Give it to the unit with a drop-in (systemctl edit arka):
[Service]
LoadCredential=admin_token:/etc/arka/secrets/admin_token
# or, encrypted to this machine: SetCredentialEncrypted=… (systemd-creds encrypt)
[admin]
token = "${credential:admin_token}"
Like ARKA_ variables, credentials can be used in arka.toml only.
Scripts#
When settings cannot express it, a Rhai script can. On a route, script = { file = "scripts/gate.rhai" } runs:
fn on_request(req) {
if req.headers["x-api-key"] != "s3cret" {
return #{ status: 401, body: "who are you?" }; // answer now
}
req.headers["x-team"] = "core"; // change the request…
req // …and pass it on
}
fn on_response(req, resp) {
resp.headers["x-served-by"] = "arka";
resp
}
As a handler (type = "script"), fn handle(req) returns the response.
req has method, path, query, args (query parameters), headers (lowercase names), client_ip, host, scheme, country, user and vars; changes to path, headers and vars apply. A response is #{ status, body, headers, content_type } (all optional but status). Scripts are sandboxed — no files, no network, no eval — and every call is stopped after max_operations (default 200000) or 100 ms, whichever comes first, so a runaway loop costs one request a 500. The time limit matters because a script runs on its worker's only thread: while it runs, that worker's other connections wait. Scripts run after authentication and before the handler; errors are logged once, not per request. Editing the file and saving (or arka reload) loads the new version; a script that does not compile keeps the host on its last good version.
Templates#
Redirect and rewrite targets, hash_key, rate-limit and connection keys, cache keys, request_headers, FastCGI params and variable sources accept placeholders:
| Placeholder | Value |
|---|---|
{scheme} |
http or https |
{host} |
the Host header, with port |
{path}, {query}, {uri} |
path, query string, path + ?query |
{method} |
the request method |
{client_ip} |
the client address (after real_ip) |
{header.Name} |
a request header |
{cookie.name} |
a cookie |
{arg.name} |
a query parameter |
{var.name} |
a host variable |
{country} |
the client's country (with [geoip]) |
{request_id} |
the request id |
{user} |
the signed-in user |
{client_cert.subject} / .issuer / .serial / .fingerprint |
the client certificate (mutual TLS) |
{1} … {9} |
captures of a regex route or rewrite |
Request pipeline#
ban (at the door) → real IP → ban → country → request id → variables → IP rules → country rules
→ HTTPS redirect → rate limit → body limit → rewrites → sign-in endpoints
→ route (path + when) → route IP / country rules → signed link
→ CORS preflight → methods → route rate limit → connections → WAF → auth
→ script on_request → handler → body replace → SSI → script on_response
→ compression → bandwidth → CORS headers → route headers → host headers
HTTP/1.1, HTTP/2 and HTTP/3 requests all go through the same pipeline.
Command line#
arka [--config FILE] run start the server (the default)
arka check validate arka.toml, every host, stream and snippet
arka reload re-read every host file and certificate (SIGHUP)
arka reopen reopen log files after rotation (SIGUSR1)
arka upgrade replace the running server with the binary on disk,
without refusing or cutting a connection (SIGUSR2)
arka stop finish in-flight requests and exit (SIGTERM)
arka init [DIR] create a ready-to-run configuration
arka new-host ID --domain NAME [--listen ADDR] [--root DIR | --proxy HOST:PORT]
arka sign-url HOST PATH [--expires 1h] make a signed, expiring link
arka passwd NAME [--stdin] hash a password for `auth` users
arka token [--rotate] print the control panel's token (or replace it)
arka backup [--out FILE] [--keep N]
archive arka.toml, host/stream/snippet files, the token
and Arka's state (accounts, certificates, bans, history)
Backups. arka backup writes one .tar.gz (mode 0600 — it holds private keys) to <state_dir>/backups/arka-<time>.tar.gz, with a MANIFEST naming where each part came from; logs, temporary files, caches and downloaded updates are left out. --keep N keeps only the newest N there. The .deb runs it daily (arka-backup.timer, newest 7 kept). Copy the archives off the machine — a backup on the same disk does not survive the disk. To restore: stop Arka, unpack config/ and state/ back to the places the MANIFEST names, start it.
State on disk. Arka's own files — panel accounts and tokens, certificates and their keys, bans, the last good version of every host, host files saved from the panel — are written whole: to a temporary file, flushed to the disk, then renamed over the old one. A crash or power cut leaves the old file or the new one, never half of one. A certificate whose key and chain come from two different saves (a crash between the two) is not served; it is ordered again.
Background work restarts. The certificate renewer, the control panel and the access-log writer each run on a thread of their own; should one crash, or stop with an error, it starts again (after 1–60 s, growing up to 5 minutes while failures repeat), and the error is logged.
A stuck worker. Each worker serves its connections on one thread, so one blocking call stalls every one of them. Workers beat four times a second: a late beat logs a warning and shows in arka_worker_stall_seconds, and under systemd with WatchdogSec= (the .deb's unit sets 30 s) Arka tells systemd it is alive only while every worker beats — a worker stuck for half that time gets the service restarted instead of answering nothing for ever.
Upgrades work the way nginx swaps binaries: the running process starts the binary at its own path (the new version, once you have replaced the file), handing over every listening socket with its accept queue; the new process starts serving and says so; the old one stops accepting, finishes its in-flight requests and exits. If the new binary fails to start, the old one keeps serving. Under systemd, set NotifyAccess=all.
Metrics#
GET /metrics on the panel's address serves Prometheus metrics:
arka_http_requests_total,arka_http_response_bytes_total,arka_http_request_duration_seconds— by host and status class;arka_http_events_total{event=…}—rate_limited,access_denied,auth_failed,waf_blocked,geo_blocked,connections_limited,banned,queued,queue_rejected,cache_hit,cache_miss,cache_stale,upstream_error;arka_upstream_up,arka_upstream_in_flight,arka_upstream_requests_total,arka_upstream_failures_total;arka_cache_bytes,arka_cache_entries,arka_connections_active,arka_connections_total,arka_connections_shed_total,arka_tls_handshakes_total;arka_certificate_expiry_timestamp_seconds{host,source}— when each certificate expires (alert onarka_certificate_expiry_timestamp_seconds - time() < 14 * 86400), andarka_certificate_renewal_failing{host}— 1 while the last attempt to obtain or renew an ACME certificate failed;arka_hosts,arka_host_serving_previous,arka_log_records_dropped_total,arka_build_info,arka_start_time_seconds, and the usualprocess_*metrics;arka_worker_stall_seconds{worker}— each worker's longest stall since the last scrape (a few milliseconds is normal; a second means something blocked it);arka_disk_free_bytes{path},arka_disk_size_bytes{path},arka_disk_low{path}— each disk Arka writes to (path: the first of its folders there), and whether it is below[logs] disk_reserve;arka_access_log_pausedandarka_access_log_lines_skipped_total— access logs paused for want of space.
It needs the token (Prometheus: authorization: { credentials_file: … }) unless admin.public_metrics = true. GET /healthz answers 200 without a token, for load balancers and orchestrators.