Skip to content

Configuration file ​

The whole application is configured from a single JSON file; there are no separate .env files. The path is passed with -c:

bash
./exec/cwfr -c /path/to/config.json      # daemonises (Release)
./exec/cwfr -c /path/to/config.json -f   # stay in the foreground

-f is what you want under a supervisor or in a container: a process that forks and exits reads to systemd or docker as one that died immediately.

Reload the configuration without stopping the server with SIGUSR1 (pkill -USR1 cwfr); the behaviour is set by main.reload. See Hot reload for details.

How the configuration is read ​

The file is parsed in full before the server listens on any socket, and any error stops start-up with a message in the journal (syslog, journalctl -t cwfr). The order is main → servers → databases → storages → mimetypes → sessions → task_manager → translations → mail, then the HTTP/2 and HTTP/3 policies from main.env.

Two practical consequences:

  • Handler paths are checked at start-up. The .so files named in routes and task_manager are loaded immediately and the function names resolved through dlsym. A typo in a path or a function name means the server does not come up.
  • The exit code means something. The process does not report success until the config has been read, validated and applied, and every worker is listening. So cwfr -c config.json && ... does what it looks like it does.

Which sections are required ​

SectionRequiredWhat happens without it
mainyesStart-up fails
serversyes, non-emptyStart-up fails
mimetypesyes, non-emptyStart-up fails
databasesnoNo database access
storagesnoNo file storages
sessionsnoNo sessions
task_managernoThe scheduler does not start
translationsnoi18n disabled
mailnoDirect delivery to the recipient's MX, mail goes unsigned
migrationsnoNothing: the section is not read at runtime

main section ​

Required. Every key below except env is mandatory — a missing one stops start-up.

workers number required ​

How many workers accept connections and read/write data. Must be ≥ 1.

Workers are threads of one process, not separate processes. Anything documented as "per process" (the QUIC memory budget, the compressed-representation cache, the QUIC connection limit) therefore is not multiplied by their number.

threads number required ​

How many threads run handlers and build responses. Must be ≥ 1. Kept separate from the workers on purpose: a slow handler must not stop the sockets from being read.

reload soft | hard required ​

Hot-reload mode (on SIGUSR1):

  • soft — reload keeping active connections
  • hard — reload forcibly closing connections

The code default is soft, but the key still has to be present in the file.

client_max_body_size number required ​

Maximum request body size in bytes, ≥ 1.

In HTTP/1.1, a Content-Length header above the limit results in 400 Bad Request while the headers are still being parsed; the body is never read, the response carries Connection: close, and the connection is closed. Incoming requests with Transfer-Encoding, including chunked, are not supported and also receive 400 Bad Request with the connection closed. In HTTP/2, exceeding the limit while receiving the body resets the stream (RST_STREAM with INTERNAL_ERROR); in HTTP/3, it results in 413 Content Too Large. The same value caps WebSocket frames and the responses the built-in HTTP client accepts.

tmp string required ​

Temporary file directory: large request bodies and uploads are spooled there. No trailing slash — "/tmp/" is a configuration error, "/tmp" is correct.

It is also the fallback directory for the shadow copies of rebuilt handlers, used when the directory holding the .so itself is not writable (see Hot reload).

gzip array of strings required ​

MIME types eligible for automatic response compression. The key is mandatory, but the array may be empty ([]) — that turns compression off.

A type on this list becomes negotiable, not always compressed. A response is compressed only if it is at least 1 KB and the request's Accept-Encoding allows it: gzip named explicitly, or * when gzip is not mentioned; gzip;q=0 is a refusal, and a request with no Accept-Encoding at all gets uncompressed bytes. Every response whose type is on this list carries Vary: Accept-Encoding — compressed or not — so a shared cache tells the two representations apart; their ETags differ too (the compressed one gets a -gzip suffix).

Compressing static files is expensive and paid again on every request: a 92 KB file costs about 410 µs of CPU in zlib — four times everything else the response does. Two keys in main.env remove that work, each in its own way; both are off by default.

gzip_static boolean main.env ​

Serve a ready-made <file>.gz when one sits next to the file: the server then compresses nothing at all — the body comes off disk with Content-Length instead of chunked.

The twin is used only if it is not older than the original — a stale build artefact is never served, on-the-fly compression takes over instead. The check costs one open() and runs only for responses that would have been compressed anyway (type on the main.gzip list, client accepts gzip, size at least 1 KB), so a client that did not ask for compression does not pay for it.

json
"env": { "gzip_static": true }

The server does not create these files — it only serves the ones already there, and writing them is the build's job. If your build cannot, post-process the output directory:

bash
find dist -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) -size +1k \
    -exec gzip -9 -k -f {} +

Only the types listed in main.gzip are worth compressing: no twin is looked for next to a file of any other type. The -size +1k threshold mirrors the server's — a file under 1 KB is not compressed and its .gz is never asked for.

Regenerate after every build. gzip -k preserves the original's mtime, so a fresh twin passes the staleness check; a twin left over from the previous build will be older than the new files, and the server quietly falls back to on-the-fly compression without saying so in the log.

Content-Length in such a response is the length of the compressed bytes: HTTP counts the body after the content-coding is applied, and chunked is unnecessary because the size is known before the first byte goes out (with on-the-fly compression it is not, which is why that path uses chunked). The uncompressed size is not reported in any header, but it does go into the ETag — the validators are taken from the original before the .gz is substituted.

gzip_cache_size, gzip_cache_max_file numbers main.env ​

An in-memory cache of compressed representations: a file is compressed once, and every later request gets the finished bytes. Useful where you cannot place .gz files next to the originals.

KeyDefaultDescription
gzip_cache_size0 (off)Total memory budget for compressed representations, in bytes
gzip_cache_max_file1048576Largest source file that will be cached, in bytes
json
"env": { "gzip_cache_size": 33554432, "gzip_cache_max_file": 1048576 }

The entry key is the path, the mtime and the size of the source file, so an overwritten file is never served from stale content: change any of the three and it is a different resource, compressed afresh. The budget is kept by least-recently-used eviction, and gzip_cache_max_file stops one large file from evicting everything else; an entry being read by an unfinished response survives until that response ends, even if the cache has already released it. A ceiling larger than the budget is clamped to the budget — an entry that size could never fit anyway.

The cache is one per server: workers are threads of a single process, so gzip_cache_size is not multiplied by their number.

The ETag does not depend on the mode: a compressed representation gets a weak one (W/"…-gzip") describing the resource rather than the exact bytes, so a cache hit, a .gz from disk and on-the-fly compression are interchangeable to a client cache. The order is: .gz from disk first, then the in-memory cache, then on-the-fly compression.

log object required ​

Logging settings. enabled and level are mandatory, access is not. The journal goes to syslog — read it with journalctl -t cwfr, not from stdout.

json
"log": {
    "enabled": true,
    "level": "info",
    "access": false
}

enabled boolean ​

Enables or disables logging. With false all logging functions are ignored.

level string ​

Minimum log level. Messages of lower priority are filtered out. Accepted values (most to least critical):

  • emerg — system is unusable (0)
  • alert — action must be taken immediately (1)
  • crit — critical condition (2)
  • err / error — errors (3)
  • warning / warn — warnings (4)
  • notice — normal but significant (5)
  • info — informational (6)
  • debug — debug messages (7)

Anything else is a configuration error.

Recommendations

  • Production: info or notice — a balance between detail and performance.
  • Development: debug — maximum detail.
  • Critical systems: warning/error — important events only.

access boolean ​

The access log: one record per answered request — who asked, for what, and what they got. Optional, false by default.

This is a log of requests, not of server events, and it does not depend on enabled: wanting request records without debug chatter is the ordinary case, not an exotic one. So that journalctl -t cwfr stays readable, access records go to their own syslog facility, local7:

bash
journalctl -t cwfr SYSLOG_FACILITY=23 -o cat   # requests, one line each
journalctl -t cwfr SYSLOG_FACILITY=1           # server events

The format is fixed — Apache's vhost_combined with the processing time appended:

example.com 203.0.113.7 - - [08/Sep/2026:22:41:07 +0700] "GET /page?a=1 HTTP/2" 200 1234 "https://ref/" "Mozilla/5.0" 0.002

The fields, in order: the vhost (the Host value), the client address, two dashes where identd and the HTTP user would be, the time, the request line, the status code, the body size, Referer, User-Agent, and the duration in seconds. For GoAccess:

log-format %v:%^ %h %^[%d:%t %^] "%r" %s %b "%R" "%u" %T
date-format %d/%b/%Y
time-format %H:%M:%S

Records are identical across HTTP/1.1, HTTP/2 and HTTP/3, and cover everything the vhost serves — static files, handler responses, 304, the 429 of a rate limit, redirects. The body size is the bytes that actually went on the wire: compressed for a compressed response, the part size for a Range, zero for a 304. The request line is the one the client sent: a redirect is recorded under the path that was asked for, not under its destination.

What does not reach a record:

  • a request the parser refused before it finished reading it (a malformed request line, an unknown Host) — the method and the target are already gone by then, and dashes stand in their place;
  • a response that could not be finished on the socket: a torn response was not served.

Delivery in batches ​

Records accumulate in the worker's buffer and reach syslog a batch at a time — one send per batch, not per request. This is not optimisation for its own sake: journald charges per journal entry, not per byte, and one send per response cost 4.3 µs, more than the rest of the response put together. A batch of sixty records costs about 60 ns each.

What that means in practice:

  • One journal entry may hold several lines. journalctl -o cat — what an analyser is fed — prints them as separate lines, exactly one per request; the default journalctl view indents the continuations. Every line carries its own timestamp, so nothing about a record depends on which batch carried it.
  • A record may lag by up to about half a second. The buffer is flushed when it fills, on the worker's timer (a 500 ms tick), and at shutdown. On a quiet server that is the upper bound on the delay; no record is lost, including across a configuration reload.
  • Ordering between workers is batch-grained. Within one connection it is exact — a connection is served by one worker. nginx's per-worker buffers have the same property.

The cost

On a synthetic static benchmark (wrk, keep-alive) turning the access log on costs about 5% of throughput: 159.6k → 151.9k requests per second. The profile attributes 1.7% of the worker to it, 0.6% of that being the delivery itself; the rest is journald burning CPU in the next process.

Before batching — one send per request — the same thing cost 24% (159.6k → 121.6k), and on a faster machine as much as half. Batching the syscalls (sendmmsg) does not help here: what is expensive is journald taking each entry, not the call that hands it over.

With false there is no cost at all: the flag is tested before anything is formatted and before the clock is read; on the benchmark the disabled log is indistinguishable from a build without it (158.9k against 159.6k).

modules array of strings ​

Application shared libraries that cwfr and migrate load at start-up. Optional; the array order is the load order.

json
"modules": ["/srv/myapp/lib/cwfr/libapp.so"]

This is the one place where an application tells the core what the core cannot work out for itself:

  • the middleware names referenced by servers.*.http.middlewares and servers.*.websockets.middlewares;
  • the destructors for whatever the application attaches to httpctx_t/wsctx_t through httpctx_set_user_data().

These used to be link-time symbols, which forced the application's code to be compiled into libcwfr_framework.so. The framework is now built and installed once, knowing nothing about any application, and handlers are written and rebuilt afterwards.

The module contract ​

The library must export int app_init(void). Returning 0 aborts start-up.

c
#include <stdio.h>

#include "model.h"
#include "httpcontext.h"
#include "wscontext.h"
#include "middleware_registry.h"
#include "httpmiddlewares.h"

int app_init(void) {
    /* One owner per process: with several modules, the second one gets 0. */
    if (!httpctx_set_user_data_free(model_free) || !wsctx_set_user_data_free(model_free))
        return 0;

    if (!middleware_registry_register("middleware_http_auth", (middleware_fn_p)middleware_http_auth)) {
        fprintf(stderr, "app_init: failed to register middleware_http_auth\n");
        return 0;
    }

    return 1;
}

Not log_error()

app_init() runs before the parsed configuration is published, so the logger is still silent at that point (log_message() returns while env() is NULL). Report errors on stderr, or the message is lost and a failed start leaves nothing behind but a non-zero exit status. The core mirrors its own loader errors to stderr for exactly this reason.

How it is loaded ​

The path is handed to dlopen() verbatim, exactly like a route's "file" field. An absolute path is taken literally, a relative one is resolved against the process's current directory, and a bare name with no / goes through the normal dynamic loader search (LD_LIBRARY_PATH, RPATH, ldconfig). Relative paths are a common source of "file not found" under service managers and are rarely what you want.

The flags are RTLD_NOW | RTLD_LOCAL:

  • RTLD_NOW — every unresolved symbol in the module is diagnosed here, with the path in the message, instead of later as a lazy-binding abort inside a worker;
  • RTLD_LOCAL — a module's symbols stay out of the global lookup scope, so several modules never collide over a shared name. Handlers are unaffected: a handler .so records the module in DT_NEEDED, and the loader satisfies that from the already-loaded instance by SONAME, whatever scope it was opened in.

Ordering against the rest of the config ​

app_init() is called after main is parsed and before servers is. It cannot be otherwise: a route names its middleware, and the name only enters the registry once it has been registered. An unknown name is a configuration error and the server will not start.

migrate does the same thing in the same order — it parses servers too, so it needs the same populated registry.

Several modules ​

There may be any number of modules. They are loaded in array order, and each one's app_init() runs as soon as it is loaded. This is the normal layout for a monorepo whose application is built from several independent parts:

json
"modules": [
    "/srv/app/lib/cwfr/libcommon.so",
    "/srv/app/lib/cwfr/libidentity.so",
    "/srv/app/lib/cwfr/libscheduler.so"
]

What is shared across the process and what belongs to a module:

ScopeConflict
Middleware namesone registry per processa repeated name is refused and app_init() returns 0
The ctx->user_data destructorone per processa second module with a different function gets 0 and a message
A module's own symbolsprivate to the modulenone: modules are opened with RTLD_LOCAL

Symbol isolation matters: two modules may each carry their own user_create(), and without it the second would silently bind to the first module's implementation. RTLD_LOCAL prevents that and costs nothing — a handler reaches its module through DT_NEEDED, not through the global lookup scope.

ctx->user_data has one owner

httpctx_set_user_data_free() and wsctx_set_user_data_free() return 0 when the destructor is already held by a different function, and say so. Registering the same function again succeeds — otherwise app_init() could not be re-run on reload.

Check the return value: unchecked, the conflict is only a line in the journal and the server comes up using the wrong destructor for half its requests.

Config reload ​

On reload (SIGUSR1) the middleware registry is cleared and app_init() runs again, so it must be idempotent: registering the same name twice returns 0, which would abort start-up if the function accumulated state.

It runs twice per reload: once on the validation pass that decides whether the new configuration is usable, and once for real. Both start from an empty registry, so no duplicate ever arises — but whatever app_init() does besides registering happens twice per reload.

Where the module has been rebuilt, app_init() runs on the new instance, whose static variables are its own: no state carries across generations. Anything that has to survive a reload belongs outside the module.

The library belongs to the configuration generation: rebuilt in place, it is loaded again — as a copy, under that generation's SONAME — and the previous one is unloaded when the last thread of the previous generation is gone. Unmapping it any earlier is never safe, because a worker may still be executing a middleware from it.

A rebuilt module is picked up by SIGUSR1

The path does not have to change. How it works, what it costs and when it does not apply after all is in Hot reload → The application module.

One reload can add both a new middleware in app_init() and a route naming it: the validation pass runs the new module's app_init(), so the names a rebuilt module introduces are visible to it.

Validation and errors ​

The value must be an array of non-empty strings; anything else is a configuration error. Each entry then faces three checks, and any of them aborts start-up with a message on stderr and in the log:

What went wrongMessage
The file cannot be openedcan't load module <path>: <dlerror text>
No app_init()module <path> exports no app_init()
app_init() returned 0app_init() failed in <path>

If a module exports no app_init() but does export middlewares_init() — the hook the core no longer calls — a migration hint is appended. Without it the mistake would surface much later and in an unrelated shape: failed to find middleware <name> while servers is parsed.

The first two checks also run before a reload, while the new configuration is validated — but only for modules not already loaded in this process, and without calling app_init(). A typo in a path therefore refuses the reload while the old generation is still serving. app_init() failing stays fatal: that is an application bug rather than a configuration mistake, and it shows up on the first start.

Without this key ​

The server starts, but the middleware registry stays empty and ctx->user_data has no destructor. That is a valid configuration for serving static files, or for routes with no application middleware; the moment the configuration names a middleware, start-up fails.

See Middleware, and core/INSTALL.md — the sections "The application module" and "Building an application against an installed framework".

env object ​

The only optional key in main. A key-value store holding both your application's own settings and every behavioural parameter of the protocols.

Only scalar values are copied: string, number, bool, null. Nested objects and arrays are silently dropped — no env_get_* function can read them.

The .env file ​

Keys can also come from a .env file placed next to config.json — one key=value pair per line:

dotenv
# a comment
refresh_token_expiration=15552000
feature_x_enabled=true
app_name=backend
password="p@ss # not a comment"  # a comment after the value

Blank lines and lines starting with # are skipped, an export prefix is allowed, whitespace around keys and values is trimmed. Matching quotes are stripped, and a quoted value always stays a string (for an unquoted value, everything after # is dropped as a comment). The type is inferred: true/false reads as a boolean, anything that parses as a number reads as a number, everything else is a string — from there a key is indistinguishable from one set in main.env.

The path is overridden with env_file. If a key is set both in main.env and in .env, main.env wins.

env_file string main ​

Name of the variables file instead of .env. A relative path is resolved against the directory of config.json:

json
"env_file": "secrets/.env.production"

If the file cannot be opened, or a line in it has no =, a message goes to the journal and startup continues without those keys.

Application keys ​

json
"env": {
    "refresh_token_expiration": 15552000,
    "feature_x_enabled": true,
    "app_name": "backend"
}
c
long long ttl = env_get_llong("refresh_token_expiration", 3600);
int enabled  = env_get_bool("feature_x_enabled", 0);
const char* name = env_get_string("app_name", "default");

Available: env_get_string, env_get_int, env_get_llong, env_get_bool, env_get_double, env_get_ldouble — each takes a key and a default. A missing key, or one of the wrong type, yields the default without an error.

When a call-site default is not expressive enough — for example, a missing key must be distinguishable from an explicitly set value — use the checked variants: env_get_string_checked, env_get_llong_checked, env_get_bool_checked. They return a status: 0 — the key is absent, 1 — the key exists with a suitable type (the value is written to the out parameter), -1 — the key exists but has the wrong type. The standalone env_config_get_string_checked / env_config_get_llong_checked / env_config_get_bool_checked do the same for an explicitly passed env_t* rather than the global configuration — handy in code that works with a configuration before it is published, and in tests.

Runtime keys ​

Everything else configured through main.env is a server parameter. A full index; each parameter is described in detail on the linked page.

GroupKeysDocumented in
Observabilitymetricsbelow
Static compressiongzip_static, gzip_cache_size, gzip_cache_max_fileabove
HTTP/2: lifecyclehttp2_idle_timeout_sec, http2_ping_interval_sec, http2_ping_ack_timeout_sec, http2_settings_ack_timeout_sec, http2_recv_window_initial, http2_recv_window_max, http2_write_quantumHTTP/2 → Configuration
HTTP/2: abuse protectionhttp2_max_header_list_size, http2_max_continuation_frames, http2_abort_rate, http2_abort_burst, http2_ctrl_rate, http2_ctrl_burst, http2_max_out_backlogHTTP/2 → Abuse protection
HTTP/3: endpoint and resourceshttp3_max_connections, http3_buffer_memory_limit, http3_rx_batch, http3_so_rcvbuf, http3_so_sndbuf, http3_handshake_rate, http3_handshake_burst, http3_stateless_reset_rate, http3_stateless_reset_burst, http3_version_negotiation_rate, http3_version_negotiation_burstHTTP/3
HTTP/3: connection transporthttp3_idle_timeout_sec, http3_keepalive_sec, http3_max_udp_payload_size, http3_initial_max_data, http3_initial_max_stream_data, http3_max_streams_bidi, http3_max_streams_uni, http3_recv_window_max, http3_active_cid_limit, http3_ack_delay_msHTTP/3
HTTP/3: congestionhttp3_initcwnd_packets, http3_cc, http3_pacing, http3_amplification_factorHTTP/3
HTTP/3: address validationhttp3_retry, http3_retry_threshold, http3_new_token, http3_token_lifetime_secHTTP/3
HTTP/3: protocol and abusehttp3_max_field_section_size, http3_abort_rate, http3_abort_burst, http3_ctrl_rate, http3_ctrl_burstHTTP/3
HTTP/3: versions and 0-RTThttp3_version_2, http3_early_dataHTTP/3
HTTP/3: diagnosticshttp3_qlog_dir, http3_qlog_connectionsHTTP/3

All of them are read once when the configuration is loaded and re-read on reload. A wrong type or an out-of-range value is a configuration error, not a silent fallback to the default: "http3_cc": "reno" or "http3_max_streams_uni": 1 stop start-up with a message naming the key and the accepted range.

metrics boolean ​

Enables the concurrency, lock, HTTP/2 and QUIC counters. false by default: counters that are off cost nothing.

json
"env": { "metrics": true }

The flag only collects the data — exposing it is an application handler's job. In the example application that is app/routes/bench/metrics.c, mounted on a route:

json
"/metrics": {
    "GET": { "file": "<build>/exec/handlers/bench/lib_metrics.so", "function": "metrics" }
}

GET /metrics?reset=1 returns a snapshot and zeroes the counters. The route is worth putting behind a middleware — the snapshot describes the process's internal state.

Keys that do not exist ​

main.buffer_size appears in older config.json examples and is not read by the server: the connection buffer size is fixed in code. The key is harmless but changes nothing — it can be deleted.

migrations section ​

json
"migrations": {
    "source_directory": "<project>/app/migrations"
}

This section is unused

Neither cwfr nor migrate reads migrations.source_directory. The migrate utility takes its paths as positional arguments:

bash
./exec/migrate create add_users_table ../config.json ../app/migrations/s1
./exec/migrate up all ../config.json postgresql.p1 s1

Keep the section as project documentation or delete it — behaviour is the same either way. See Migrations.

translations section ​

A list of localisation (i18n) domains. Optional.

json
"translations": [
    { "domain": "backend", "path": "/app/locale" }
]
  • domain — text domain name (required, non-empty)
  • path — directory holding the .mo files (required, non-empty)

Each domain's base locale is en. An entry with invalid fields is skipped with a message in the journal and the rest are loaded: unlike most sections, a broken entry here does not stop start-up. See Internationalisation.

task_manager section ​

Background tasks loaded from .so files and run by the scheduler. Optional; an array.

The common fields of every task — name, type, file, function — are mandatory; the schedule fields depend on type.

typeSchedule fieldsDescription
intervalintervalEvery N seconds (≥ 1)
dailyhour, minuteDaily at hh:mm
weeklyweekday, hour, minuteWeekly on the given weekday
monthlyday, hour, minuteMonthly on the given day

weekday accepts sunday, monday, tuesday, wednesday, thursday, friday, saturday; hour is 0–23, minute 0–59, day 1–31. An unknown type or an out-of-range value is a configuration error.

json
"task_manager": [
    {
        "name": "cleanup_expired_tokens",
        "type": "interval",
        "interval": 60,
        "file": "/app/build/exec/handlers/tasks/libtasks.so",
        "function": "cleanup_authorization_codes"
    },
    {
        "name": "nightly_report",
        "type": "daily",
        "hour": 3,
        "minute": 30,
        "file": "/app/build/exec/handlers/tasks/libtasks.so",
        "function": "send_report"
    },
    {
        "name": "weekly_digest",
        "type": "weekly",
        "weekday": "monday",
        "hour": 9,
        "minute": 0,
        "file": "/app/build/exec/handlers/tasks/libtasks.so",
        "function": "send_digest"
    },
    {
        "name": "monthly_invoice",
        "type": "monthly",
        "day": 1,
        "hour": 0,
        "minute": 15,
        "file": "/app/build/exec/handlers/tasks/libtasks.so",
        "function": "build_invoices"
    }
]

See Task manager.

servers section ​

Required and non-empty. Each server (virtual host) is a named entry; the name (s1, s2, …) is arbitrary and used nowhere except error messages.

Required vhost fields: domains, ip, port, root. The rest — index, ratelimits, http, websockets, tls, http3 — are optional.

domains array of strings required ​

The names this vhost answers to. Matching is against the Host header (or the SNI name on TLS), case-insensitively and after the port is stripped.

The template is converted to punycode, then into a regular expression by a few rules:

In the templateMeans
example.comAn exact name. The dot is escaped, so a.b does not match axb
*.example.comOutside brackets * expands to .* — but only at the start or the end of the string
mail.*The same from the other end
(api|www).example.comOrdinary regular expression, PCRE2: groups, alternation and [...] classes behave as in a regex
(.1|.*)example.comInside brackets the dot and the asterisk are metacharacters; no escaping is applied

The template is anchored automatically: ^ and $ are added if absent. An asterisk in the middle (a*b) is a configuration error, as is an unbalanced bracket.

A template with no metacharacters is compared directly, bypassing PCRE2 — noticeably faster, and precisely why the dot is escaped rather than left as a metacharacter: otherwise no real domain name would ever take the fast path.

Do not put a port in domains

The port is stripped from Host before matching, so an entry like "www.example.com:8080" will never match. The port is set by the port field.

A vhost is identified by the triple (address, domain, port), and two entries sharing that triple are a configuration error.

ip string required ​

The address to listen on — IPv4 or IPv6. 127.0.0.1, 0.0.0.0, ::1, :: and the bracketed form [::1] are all accepted. The address applies both to TCP (HTTP/1.1, HTTP/2, WebSocket) and to the HTTP/3 UDP endpoint.

An IPv6 socket is created v6-only: it does not accept IPv4 traffic, because a dual-stack socket would hand back IPv4 addresses in v4-mapped form, which makes a datagram's local address ambiguous and depends on the system's net.ipv6.bindv6only. A site reachable over both families is therefore two entries in servers with the same domains and port, differing only in ip:

json
"servers": {
    "site_v4": { "domains": ["example.com"], "ip": "0.0.0.0", "port": 443, "...": "..." },
    "site_v6": { "domains": ["example.com"], "ip": "::",      "port": 443, "...": "..." }
}

Vhost uniqueness is checked on "address + domain + port", so such a pair is not a collision.

An invalid address (a typo like 127.0.0.300 or ::1x) stops start-up with a message naming the value — the server does not try to guess it.

port number required ​

The server's TCP port (usually 80 for HTTP, 443 for HTTPS). It also becomes the default UDP port for HTTP/3.

root string required ​

The static file root. It must exist at start-up, otherwise the server refuses to start. A trailing slash is stripped.

Everything file-related is resolved from this directory: both the static files served when no route matches and the paths in static_file.

index string ​

The index file name served for a directory. Defaults to index.html. One name, not a list.

ratelimits object ​

Named rate-limiting profiles (token bucket). Each profile sets burst (bucket capacity — the peak number of requests) and rate (tokens refilled per second). Each allowed request spends one token; the bucket refills as time passes rather than resetting at each second boundary.

json
"ratelimits": {
    "default":   { "burst": 100, "rate": 50 },
    "strict":    { "burst": 1,   "rate": 1 },
    "unlimited": { "burst": 1,   "rate": 0 }
}

Both fields are mandatory and must be integers. Each client IPv4 address or IPv6 /64 prefix gets a bucket of its own. rate: 0 turns the profile off: such a limiter lets every request through, and burst is not consulted. There is no bucket that never refills ("burst requests, then refusal for good") — the strictest profile is { "burst": 1, "rate": 1 }, one request per second.

The profiles limit nothing on their own — they have to be assigned: through ratelimit in http, in websockets, or on an individual route method. Referring to a profile name that does not exist is a configuration error.

For example, { "burst": 100, "rate": 50 } allows up to 100 consecutive requests from a full bucket, then replenishes up to 50 tokens per second. burst bounds the reserve; rate controls how quickly it recovers.

Clients are identified by the connection address: the full IPv4 address or the IPv6 /64 prefix. Buckets live in each worker process and are not shared across workers or server instances. A profile name shares settings, not a bucket across all assignments: separate routes with their own profiles have separate limiters; routes without one use the http.ratelimit limiter.

The ratelimit field is declared inside a method, but the limiter belongs to the whole route and applies to all its methods. Use the same profile for methods of one route; use separate routes for different limits.

http object ​

HTTP routes, middleware, response headers and redirects. All five nested keys are optional.

ratelimit string ​

The default rate-limiting profile for all HTTP routes of the vhost without a ratelimit of their own, and statics from root served to a request that matched no route.

For file routes served from root and requests that match no route, the limit is checked before looking up the file. Missing files also spend tokens; an exhausted limit returns 429 with Retry-After: 1.

Routes without their own profile and static requests outside routes share one bucket per client IPv4 address or IPv6 /64 prefix within the vhost limiter in each worker process. The route's profile takes precedence, including rate: 0, which disables limiting for that route. Without an assigned profile, requests are not limited. Redirects from http.redirects do not check this limiter.

middlewares array of strings ​

Middleware applied to every HTTP route. Names come from the application registry — app_init() in the module named by main.modules registers them; an unknown name is a configuration error.

json
"middlewares": ["middleware_http_auth"]

See Middleware.

routes object ​

HTTP routes. The key is a path, the value an object of METHOD → { handler }.

Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS. Others (CONNECT, TRACE) are not declared in routes.

json
"routes": {
    "/api/users": {
        "GET":  { "file": "handlers/models/lib_modeluser.so", "function": "list", "ratelimit": "strict" },
        "POST": { "file": "handlers/models/lib_modeluser.so", "function": "create", "ratelimit": "strict" }
    },
    "/api/users/{id|\\d+}": {
        "PATCH": { "file": "handlers/models/lib_modeluser.so", "function": "update" }
    }
}

A path may be literal, carry named parameters ({id|\d+}) or be a regular expression — but not both at once. The syntax is covered in Routing.

Handler fields:

  • file — path to the .so holding the handler. The library is loaded at start-up and shared by every route that names it. Rebuilt in place, it is picked up on the next SIGUSR1 — the path does not have to change (see Hot reload)
  • function — the exported function name. Resolved at start-up; not found means the server refuses to start
  • static_file — a static file path. When present, file/function are neither required nor called: the route serves the file. The path is always resolved relative to root — a leading / is stripped, it does not mean the filesystem root. Capture groups from the route's regular expression can be substituted as {1}, {2}, … (the same notation as redirects), so one route can serve a whole directory:
    json
    "/assets/(.*)": {
        "GET": {
            "static_file": "/assets/{1}",
            "cache_control": "public, max-age=31536000, immutable"
        }
    }
  • storage — the name of a storage from the storages section that static_file is taken from. Without it the path is resolved against root, as before. It requires static_file and cannot be combined with file/function; an unknown name refuses the start. Unlike ordinary statics, a storage route does run middlewares, and cache_control lands only on what the storage served (200/206/304) — not on a 404, a 429 or a middleware's refusal. For an S3 storage the object is asked for with HEAD first, the body arrives in 8 MB chunks (further clamped by client_max_body_size), and a client Range is cut by S3 itself — see Serving from a storage:
    json
    "/videos/(.*)": {
        "GET": { "static_file": "{1}", "storage": "media" }
    }
  • cache_control — the Cache-Control header for whatever the route answers with, a file or a handler alike. Without it every file response carries Cache-Control: no-cache (revalidate on each use) — safe, but it makes the client re-download immutable build artefacts. Put immutable caching on routes whose files carry a content hash in the name, and leave pages on the default. A handler that sets its own Cache-Control keeps it — the route value is a default, not an override; and a missing static_file answers 404 without the header
  • ratelimit — the rate-limiting profile for the whole route, overriding http.ratelimit. The field is declared inside a method but applies to all methods of the route; if different methods specify different profiles, the last loaded profile is used. It applies to handler routes and to routes with static_file, including files from root and from storage (see Rate limiting for static files)

A request matching no route is served as a static file from root.

redirects object ​

Redirect rules. The key is a path or a regular expression, the value the target; capture groups are substituted as {1}, {2}, …:

json
"redirects": {
    "/user": "/persons",
    "/user(.*)/(\\d)": "/user-{1}-{2}",
    "/section1/(\\d+)/section2/(\\d+)/section3": "/one/{1}/two/{2}/three"
}

Redirects are checked before routes.

The request's query string is carried onto the target when the target has no ? of its own: /old?utm_source=ya lands on /index.html?utm_source=ya. A target with its own parameters ("/old": "/new?b=2") is left alone — they were written deliberately, and merging two sets would be a surprise. The match is still made against the path without the query, so (.*) never captures the query.

headers object ​

Response headers this vhost puts on every answer it gives. The key is the header name, the value a string; an empty value is a configuration error.

json
"headers": {
    "X-Content-Type-Options": "nosniff",
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
    "Referrer-Policy": "strict-origin-when-cross-origin"
}

This is where the headers decided once per site live. A handler cannot reach them: static files are served by the core, and middleware runs on routes — while static files are what a request gets when it matches none. Here the header lands on everything: on static files, on a handler's output, on send_default(404), on a 304 — and identically over HTTP/1.1, HTTP/2 and HTTP/3.

The configured value is a default: a handler that set its own Referrer-Policy keeps its value, and no duplicate field appears. This is the same rule a route's cache_control follows.

websockets object ​

WebSocket configuration. All nested keys — default, ratelimit, middlewares, routes — are optional, but the presence of the section itself is meaningful.

json
"websockets": {
    "default": { "file": "handlers/ws/lib_wsindex.so", "function": "default_" },
    "ratelimit": "strict",
    "middlewares": ["middleware_ws_auth"],
    "routes": {
        "/": { "GET": { "file": "handlers/ws/lib_wsindex.so", "function": "echo" } }
    }
}
  • default — the handler for frames matching no route. Without it the built-in default handler is used
  • ratelimit, middlewares — as in http, but for WebSocket traffic
  • routes — routes; the method key here is also GET

The section also governs HTTP/2

A vhost without a websockets section answers Extended CONNECT (WebSocket over HTTP/2, RFC 8441) with 501 Not Implemented. The capability is advertised per connection while serving is per vhost, so an empty "websockets": {} is a meaningful "WebSocket is allowed here".

See WebSocket requests and Broadcasting.

tls object ​

TLS/SSL settings. All three fields are mandatory when the section is present; an empty string is not allowed.

json
"tls": {
    "fullchain": "/path/to/fullchain.pem",
    "private": "/path/to/privkey.pem",
    "ciphers": "TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256 ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384"
}
  • fullchain — PEM certificate chain
  • private — PEM private key
  • ciphers — cipher list: TLS 1.3 suites separated by spaces first, then the TLS 1.2 list separated by colons

The vhost is selected by SNI on TLS, and the certificate comes from the matching vhost. HTTP/2 requires an AEAD cipher with ephemeral key exchange (RFC 9113 §9.2.2) — a connection on a weak cipher gets GOAWAY(INADEQUATE_SECURITY), while HTTP/1.1 on the same cipher keeps working.

See SSL certificates.

http3 object ​

Enables HTTP/3 (over QUIC) for the vhost. Requires the -DINCLUDE_HTTP3=yes build flag (OpenSSL ≥ 3.5) and a tls section — QUIC has no cleartext mode; http3.enabled without tls stops start-up, as does enabled in a build without HTTP/3 support. TCP keeps serving HTTP/1.1 and HTTP/2 alongside.

json
"http3": {
    "enabled": true,
    "port": 443,
    "alt_svc": true,
    "alt_svc_max_age": 86400
}
KeyTypeDefaultDescription
enabledboolfalseTurns HTTP/3 on. Required for h3 to work
portnumberthe vhost's TCP portUDP port for QUIC (1–65535)
alt_svcbooltrueAdvertise h3 in the Alt-Svc header over HTTP/1.1 and HTTP/2
alt_svc_max_agenumber86400Alt-Svc cache lifetime (seconds, ≥ 0)

Only the port goes into Alt-Svc (h3=":443"; ma=86400), never a host name: an empty host per RFC 7838 means "the same one", which keeps the header correct for every vhost sharing the listener.

QUIC's own behaviour is tuned by the http3_* keys in main.env — those are per process, not per vhost. See HTTP/3.

databases section ​

Database connections. Optional. Each driver is a non-empty array of hosts; in code a connection is addressed as <driver>.<host_id> (e.g. postgresql.p1, redis.r1, sqlite.local).

Only the drivers enabled at build time are compiled in (-DINCLUDE_POSTGRESQL=yes and so on). A driver named in the config but missing from the build is skipped with a message in the journal — start-up continues, but code addressing it will not find the host.

In every driver an unknown field is a configuration error, as is repeating a field twice.

postgresql ​

json
"postgresql": [{
    "host_id": "p1",
    "ip": "127.0.0.1",
    "port": 5432,
    "dbname": "mydb",
    "user": "dbuser",
    "password": "dbpass",
    "connection_timeout": 3,
    "schema": "public"
}]
FieldTypeRequiredDescription
host_idstringyesHost identifier; addressed as postgresql.<host_id>
ipstringyesDatabase server address
portnumberyesPort
dbnamestringyesDatabase name
userstringyesUser
passwordstringyesPassword (may be an empty string)
connection_timeoutnumberyesConnection timeout, seconds
schemastringnoSchema tables are looked up in. Unset means current_schema()

mysql ​

json
"mysql": [{
    "host_id": "m1",
    "ip": "127.0.0.1",
    "port": 3306,
    "dbname": "mydb",
    "user": "dbuser",
    "password": "dbpass",
    "charset": "utf8mb4",
    "connection_timeout": 5
}]
FieldTypeRequiredDescription
host_id, ip, port, dbname, user, password—yesAs for PostgreSQL
charsetstringnoConnection charset. Defaults to utf8mb4
connection_timeoutnumbernoConnection timeout, seconds. 0 or absent means no timeout is set

redis ​

json
"redis": [{
    "host_id": "r1",
    "ip": "127.0.0.1",
    "port": 6379,
    "dbindex": 0,
    "user": "",
    "password": ""
}]
FieldTypeRequiredDescription
host_id, ip, port—yesIdentifier and address
dbindexnumberyesRedis database index, 0–15
userstringnoUser (Redis ACL)
passwordstringnoPassword

sqlite ​

json
"sqlite": [{
    "host_id": "local",
    "path": "/var/lib/app/data.sqlite",
    "journal_mode": "WAL",
    "busy_timeout": 5000
}]
FieldTypeRequiredDefaultDescription
host_idstringyes—Host identifier
pathstringyes—Database file path; ":memory:" for in-memory
journal_modestringnoWALPRAGMA journal_mode value
busy_timeoutnumberno5000PRAGMA busy_timeout in ms; non-negative

See Databases.

storages section ​

Named file storages. Optional. The key name is what addresses the storage in code and in sessions.storage_name. The kind is set by type; an unknown type is a configuration error.

filesystem ​

json
"storages": {
    "local": {
        "type": "filesystem",
        "root": "/var/www/storage"
    }
}

root is mandatory and cannot be empty.

s3 ​

json
"storages": {
    "remote": {
        "type": "s3",
        "access_id": "your_access_id",
        "access_secret": "your_access_secret",
        "protocol": "https",
        "host": "s3.amazonaws.com",
        "port": "443",
        "bucket": "my-bucket",
        "region": "us-east-1"
    }
}

Every field is mandatory and non-empty

access_id, access_secret, protocol, host, port, bucket, region — each must be a non-empty string. In particular "port": "" (seen in older examples) stops start-up: the port is given explicitly, as a string — "443" or "80".

See Storages.

sessions section ​

A named set of session configurations. Optional. Each key is the session name used in code (session_create("backend", data, ttl)).

Every session is encrypted with AES-256-GCM; the key is derived from the mandatory secret field. The driver is set by driver:

driverRequired storage fieldDescription
filesystemstorage_nameFiles in the named storage (a name from storages)
redishost_idRedis, addressed as redis.<host_id>
databasehost_idA database, addressed as <driver>.<host_id> (e.g. postgresql.p1)
json
"sessions": {
    "backend": {
        "driver": "filesystem",
        "storage_name": "local",
        "secret": "change-me"
    },
    "scheduler": {
        "driver": "redis",
        "host_id": "redis.r1",
        "secret": "change-me"
    },
    "doc-editor": {
        "driver": "database",
        "host_id": "postgresql.p1",
        "secret": "change-me"
    }
}

An unknown driver, a missing secret or an empty storage field is a configuration error. Session lifetime is not configured here — it is passed at creation: session_create(name, data, duration).

See Sessions.

mail section ​

Email delivery. Optional; an absent section is equivalent to empty values — mail goes straight to the recipient's MX and goes out unsigned.

json
"mail": {
    "dkim_private": "/path/to/dkim_private.pem",
    "dkim_selector": "mail",
    "host": "example.com"
}
FieldDescription
dkim_privatePath to the private key file. The file is read when the configuration loads — an unreadable path stops start-up
dkim_selectorDKIM selector (part of the <selector>._domainkey.<host> TXT record name)
hostThe domain messages are signed for
relayObject: submit through an SMTP relay instead of delivering directly (see below)

Each field is individually optional, but when present it must be a non-empty string. dkim_private and dkim_selector are configured together, though: one without the other is a configuration error. With neither set, DKIM is not applied and messages go out unsigned.

mail.relay ​

The presence of the relay object switches delivery from direct to relayed: the message is handed to the configured SMTP server with authentication, and the recipient's MX records are never queried.

json
"mail": {
    "host": "example.com",
    "relay": {
        "host": "smtp.mail.ru",
        "port": 587,
        "security": "starttls",
        "user": "info@example.com",
        "password": "...",
        "auth": "auto",
        "timeout": 15,
        "verify": true
    }
}
FieldRequiredDefaultDescription
hostyes—Name or address of the relay. Resolved with getaddrinfo() (A and AAAA)
portnoper security: 587 / 465 / 25Port, 1..65535
securitynostarttlsstarttls, tls (handshake before the banner), none
userno—Login; without it no AUTH is performed
passwordno—Password. Never reaches the log
authnoautoauto, plain, login, none
timeoutno30Socket operation timeout, seconds, 1..3600
verifynotrueVerify the relay's certificate and hostname

Combining security: "none" with user is allowed but logs a warning — the credentials will travel in the clear. Every other violation stops start-up:

ConfigurationMessage
relay with no hostmail.relay.host is required
"host": ""mail.relay.host must be not empty
user without passwordmail.relay.user without mail.relay.password
password without usermail.relay.password without mail.relay.user
"security": "ssl"mail.relay.security must be one of: starttls, tls, none
"auth": "cram-md5"mail.relay.auth must be one of: auto, plain, login, none
"port": 70000mail.relay.port must be in range 1..65535
"timeout": 0mail.relay.timeout must be in range 1..3600
"verify": "yes"mail.relay.verify must be bool — true/false, not a string
"relay": "smtp.mail.ru"mail.relay must be object
dkim_selector without dkim_private (or the reverse)mail.dkim_private and mail.dkim_selector must be set together

Ready-made configurations for each mode are in Mail.

mimetypes section ​

Required and non-empty. A mapping of MIME types to file extensions: the key is a type, the value a non-empty array of extensions (without the dot).

json
"mimetypes": {
    "text/html": ["html", "htm", "shtml"],
    "text/css": ["css"],
    "application/json": ["json"],
    "application/javascript": ["js"],
    "image/png": ["png"],
    "image/jpeg": ["jpeg", "jpg"],
    "application/octet-stream": ["bin", "exe", "dll"]
}

Two tables are built: "type → extension" and "extension → type". Only the first extension of an array goes into the former — that one becomes canonical for the type; all of them go into the latter. So an extension may appear under several types (the last occurrence wins), and the order inside an array matters.

A file whose extension is not described here is served without a meaningful Content-Type, so it pays to keep the table complete. The list in the repository example mirrors nginx's and is a reasonable starting point.

Full configuration example ​

json
{
    "main": {
        "workers": 4,
        "threads": 2,
        "reload": "hard",
        "modules": ["/app/build/exec/libapp.so"],
        "env_file": "secrets/.env.production",
        "client_max_body_size": 110485760,
        "tmp": "/tmp",
        "gzip": ["text/html", "text/css", "application/json", "application/javascript"],
        "log": { "enabled": true, "level": "info", "access": true },
        "env": {
            "refresh_token_expiration": 15552000,
            "metrics": true,
            "gzip_static": true,
            "gzip_cache_size": 33554432,
            "gzip_cache_max_file": 1048576,
            "http2_idle_timeout_sec": 60,
            "http2_ping_interval_sec": 30,
            "http3_idle_timeout_sec": 300,
            "http3_keepalive_sec": 10
        }
    },
    "translations": [
        { "domain": "backend", "path": "/app/locale" }
    ],
    "task_manager": [
        {
            "name": "cleanup_expired_tokens",
            "type": "interval",
            "interval": 60,
            "file": "/app/build/exec/handlers/tasks/libtasks.so",
            "function": "cleanup_authorization_codes"
        },
        {
            "name": "nightly_report",
            "type": "daily",
            "hour": 3,
            "minute": 30,
            "file": "/app/build/exec/handlers/tasks/libtasks.so",
            "function": "send_report"
        }
    ],
    "servers": {
        "site_v4": {
            "domains": ["example.com", "*.example.com"],
            "ip": "0.0.0.0",
            "port": 443,
            "root": "/var/www/html",
            "index": "index.html",
            "ratelimits": {
                "default":   { "burst": 100, "rate": 50 },
                "strict":    { "burst": 1,   "rate": 1 },
                "assets":    { "burst": 200, "rate": 100 },
                "downloads": { "burst": 10,  "rate": 2 },
                "unlimited": { "burst": 1,   "rate": 0 }
            },
            "http": {
                "ratelimit": "default",
                "middlewares": ["middleware_http_auth"],
                "routes": {
                    "/api/users": {
                        "GET":  { "file": "/app/build/exec/handlers/models/lib_modeluser.so", "function": "list", "ratelimit": "strict" },
                        "POST": { "file": "/app/build/exec/handlers/models/lib_modeluser.so", "function": "create", "ratelimit": "strict" }
                    },
                    "/assets/(.*)": {
                        "GET": {
                            "static_file": "/assets/{1}",
                            "cache_control": "public, max-age=31536000, immutable",
                            "ratelimit": "assets"
                        }
                    },
                    "/downloads/(.*)": {
                        "GET": { "static_file": "downloads/{1}", "storage": "local", "ratelimit": "downloads" }
                    },
                    "/api/login": {
                        "POST": { "file": "/app/build/exec/handlers/models/lib_modeluser.so", "function": "login", "ratelimit": "strict" }
                    },
                    "/robots.txt": {
                        "GET": { "static_file": "/robots.txt", "ratelimit": "unlimited" }
                    }
                },
                "redirects": {
                    "/old": "/new",
                    "/user(.*)/(\\d)": "/user-{1}-{2}"
                },
                "headers": {
                    "X-Content-Type-Options": "nosniff",
                    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
                    "Referrer-Policy": "strict-origin-when-cross-origin"
                }
            },
            "websockets": {
                "default": { "file": "/app/build/exec/handlers/ws/lib_wsindex.so", "function": "default_" },
                "ratelimit": "default",
                "middlewares": ["middleware_ws_auth"],
                "routes": {
                    "/ws": { "GET": { "file": "/app/build/exec/handlers/ws/lib_wsindex.so", "function": "connect" } }
                }
            },
            "tls": {
                "fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
                "private": "/etc/letsencrypt/live/example.com/privkey.pem",
                "ciphers": "TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256"
            },
            "http3": {
                "enabled": true,
                "port": 443,
                "alt_svc": true,
                "alt_svc_max_age": 86400
            }
        },
        "site_v6": {
            "domains": ["example.com", "*.example.com"],
            "ip": "::",
            "port": 443,
            "root": "/var/www/html",
            "index": "index.html",
            "tls": {
                "fullchain": "/etc/letsencrypt/live/example.com/fullchain.pem",
                "private": "/etc/letsencrypt/live/example.com/privkey.pem",
                "ciphers": "TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256"
            },
            "http3": { "enabled": true }
        }
    },
    "databases": {
        "postgresql": [{
            "host_id": "p1", "ip": "127.0.0.1", "port": 5432,
            "dbname": "mydb", "user": "dbuser", "password": "dbpass",
            "connection_timeout": 3,
            "schema": "public"
        }],
        "mysql": [{
            "host_id": "m1", "ip": "127.0.0.1", "port": 3306,
            "dbname": "mydb", "user": "dbuser", "password": "dbpass",
            "charset": "utf8mb4",
            "connection_timeout": 5
        }],
        "redis": [{
            "host_id": "r1", "ip": "127.0.0.1", "port": 6379,
            "dbindex": 0, "user": "", "password": ""
        }],
        "sqlite": [{
            "host_id": "local", "path": "/var/lib/app/data.sqlite",
            "journal_mode": "WAL",
            "busy_timeout": 5000
        }]
    },
    "storages": {
        "local": {
            "type": "filesystem",
            "root": "/var/www/storage"
        },
        "remote": {
            "type": "s3",
            "access_id": "your_access_id",
            "access_secret": "your_access_secret",
            "protocol": "https",
            "host": "s3.amazonaws.com",
            "port": "443",
            "bucket": "my-bucket",
            "region": "us-east-1"
        }
    },
    "sessions": {
        "backend": {
            "driver": "filesystem",
            "storage_name": "local",
            "secret": "change-me"
        },
        "scheduler": {
            "driver": "redis",
            "host_id": "redis.r1",
            "secret": "change-me"
        },
        "doc-editor": {
            "driver": "database",
            "host_id": "postgresql.p1",
            "secret": "change-me"
        }
    },
    "mail": {
        "dkim_private": "/etc/dkim/private.pem",
        "dkim_selector": "mail",
        "host": "example.com"
    },
    "mimetypes": {
        "text/html": ["html", "htm"],
        "text/css": ["css"],
        "application/json": ["json"],
        "application/javascript": ["js"],
        "image/png": ["png"],
        "image/jpeg": ["jpeg", "jpg"],
        "image/svg+xml": ["svg"],
        "font/woff2": ["woff2"]
    }
}

Two references in this example point outside the configuration, and both must resolve or the server will not start:

  • middleware_http_auth under http.middlewares — a name from the registry that app_init() fills in, in the /app/build/exec/libapp.so module declared in main.modules. Drop that entry from modules and the registry is empty, so start-up fails with failed to find middleware middleware_http_auth.
  • the file fields on routes and tasks — handler paths resolved with dlopen() at start-up; a missing file or an unknown function aborts start-up just the same.

The secret values are placeholders. They belong in the file named by env_file, not in the configuration itself.

Released under the MIT License.