omnigate

Deployment

Read the README's Known limits first. In short: nothing has run under production load; neither process has a TLS listener of its own; there is one shared admin token and no user accounts.

If you ran a commit older than d79406b, read SECURITY.md before anything else: it lists what was fixed, who was affected and what to rotate. The hardening checklist for a new or existing installation is in Security hardening. What changes for an installation that is being updated is in Upgrading and under Upgrades below.

Compose with TLS

The repository has two Dockerfiles, a compose.yaml and a Caddy configuration that puts TLS in front of both processes.

1. Create the four files you own

cp .env.example .env
cp .env.control.example .env.control
cp .env.dataplane.example .env.dataplane
cp config/omnigate.example.json config/omnigate.json

All four paths are ignored by git.

2. Fill in the three environment files

Secrets are split by who needs them. Each container gets only its own file, so the data plane, which is the process that faces the internet, never holds the admin token, a media provider key, a webhook secret or a bucket credential. (Earlier commits gave one .env to both containers; see Upgrades if you have one.)

.env is read by Compose itself, for the proxy, the two memory limits and the data plane's memory budgets. Nothing in it is a secret.

Variable Value
OMNIGATE_API_HOST Host name applications will use, for example gateway.example.com.
OMNIGATE_ADMIN_HOST Host name for the dashboard, for example admin.gateway.example.com.
OMNIGATE_ADMIN_ALLOW Who may reach the dashboard host at all: CIDR ranges separated by spaces, for example 203.0.113.0/24 2001:db8::/32. The default, 0.0.0.0/0 ::/0, is everyone; narrow it to your office or VPN. See The admin allow-list.
OMNIGATE_CONTROL_MEMORY Optional. Memory limit of the control-plane container, as a Docker size (512m, 1g, 2g). 1g when it is not set.
OMNIGATE_DATAPLANE_MEMORY Optional. The same for the data-plane container. 1g when it is not set.
OMNIGATE_REQUEST_MEMORY_MB, OMNIGATE_PARSE_MEMORY_MB, OMNIGATE_CACHE_MAX_MB Optional. The data plane's three memory budgets, in megabytes; 256, 256 and 64 when not set, which fits the 1 GiB limit. compose.yaml passes them to the data-plane container, and a value in .env.dataplane does not override them. If you raise one, raise OMNIGATE_DATAPLANE_MEMORY with it: read Memory limits.

.env.control is the control plane's environment.

Variable Value
OMNIGATE_INTERNAL_TOKEN A long random string. Shared by the two processes.
OMNIGATE_ADMIN_TOKEN A different long random string, at least 16 characters. Dashboard password and admin API token.
media provider keys One variable per media.upstreams[].api_key_env named in your config.
webhook secrets The variables your config's webhook_secret_env and media.webhooks.secret_env name, or OMNIGATE_WEBHOOK_SECRET.
bucket credentials The variables media.storage.access_key_env and secret_key_env name, if storage is a bucket.

.env.dataplane is the data plane's environment.

Variable Value
OMNIGATE_INTERNAL_TOKEN The same value as in .env.control.
text provider keys One variable per upstreams[].api_key_env named in your config.

A provider whose key serves both a text upstream and a media upstream (the example config uses OPENAI_API_KEY and GEMINI_API_KEY that way) goes in both files.

One way to make a token:

python3 -c "import secrets; print(secrets.token_urlsafe(32))"

What happens with a bad value:

3. Fill in config/omnigate.json

Replace every <...> placeholder, remove the upstreams and routes you do not use, and set:

Field reference: Configuration. Provider examples: Providers.

4. Start

docker compose up -d --build

What you get:

Check it:

curl https://$OMNIGATE_API_HOST/healthz
docker compose ps
docker compose logs --tail 50 control dataplane

With the localhost defaults the certificate comes from Caddy's local CA, which your client does not trust yet. The container test copies that CA out of the proxy container (/data/caddy/pki/authorities/local/root.crt) and verifies against it.

Volumes

Volume Mounted in Holds
control-data control:/data The ledger (ledger.jsonl, sealed segments ledger.NNNNNN.jsonl, ledger.snapshot and ledger.snapshot.prev, and the per-day files of duplicate-detection ids ledger.ids.YYYY-MM-DD), jobs.jsonl and the directory jobs.jsonl.inputs/ (job inputs over 8 KiB, one file per job), keys.json (managed keys), audit.jsonl and its rotated files (admin audit log), media/ (locally stored outputs)
dataplane-data dataplane:/data omnigate-usage.spool, omnigate-config.cache.json
caddy-data, caddy-config proxy Certificates and Caddy's own state

config/omnigate.json is mounted read-only into the control container. The control plane re-reads it when it changes. Hot reload through this bind mount is not covered by the container test; if an edit is not picked up (the control log prints reloaded config when it is), restart the control container.

What the images set

The images change these defaults; everything else is as in Configuration.

Image Variable Value
control OMNIGATE_CONTROL_BIND 0.0.0.0 (the container's own network; only published ports are reachable from outside)
control OMNIGATE_CONFIG /config/omnigate.json
control OMNIGATE_LEDGER /data/ledger.jsonl
control OMNIGATE_JOBS /data/jobs.jsonl
control OMNIGATE_KEYS /data/keys.json
control OMNIGATE_AUDIT_LOG /data/audit.jsonl
control OMNIGATE_RECEIPTS /data/receipts.json
control OMNIGATE_PAYMENTS /data/payments.json
control OMNIGATE_MEDIA_DIR /data/media
dataplane OMNIGATE_LISTEN 0.0.0.0:8080
dataplane OMNIGATE_CONTROL_URL http://control:4000

Both run as user 10001 with /data as the working directory, which is where the data plane writes its spool and config cache. The control image also sets RELEASE_DISTRIBUTION=none and LANG=C.UTF-8.

Both images have a HEALTHCHECK: the control image asks its own /readyz, the data-plane image runs /omnigate-dp healthcheck. The intervals, and what Compose does with the result (nothing), are under Health and readiness probes.

compose.yaml adds, beyond what the images set:

The data-plane variables for timeouts, the connection cap, the per-key concurrency cap, the two memory budgets, the response cache and logging are not set by the image; add the ones you want to change to .env.dataplane (for example OMNIGATE_LOG_FORMAT=json for a log shipper). Control-plane variables go in .env.control.

Stopping. docker compose stop (or down, or a restart) sends SIGTERM.

See Operations.

Restarts. Both services have restart: unless-stopped. A data plane that hits an internal error it cannot continue from (a panic) logs one line, error(panic): ..., and exits with status 70; Compose starts it again, and usage that was already queued is delivered from the spool. An exit with status 70 is a bug worth reporting (SECURITY.md if it can be triggered from outside). A container that is killed for passing its memory limit is started again the same way; see Memory limits.

The control plane no longer exits when one of its stores keeps crashing. The stores (the ledger, the job store, the keys, the audit log) run as one group that is then restarted inside the process, with a pause growing from half a second to thirty. The listener stays up meanwhile, so the container does not exit and Compose restarts nothing: watch /readyz, which answers 503 with the reason for as long as it lasts (Operations).

The admin allow-list

OMNIGATE_ADMIN_ALLOW in .env is a list of CIDR ranges, separated by spaces. The proxy answers 403 to every request for the dashboard host that comes from outside them: the dashboard, the login form, /admin/*, /metrics, and the two probes. The gateway host is not affected. The default lets everyone through, which leaves the admin token as the only gate.

The container test starts a second proxy whose list contains only documentation ranges and checks that it serves the gateway host to everyone and answers 403 to everything on the dashboard host.

Testing the Compose file

python3 tools/container_test.py --compose

brings up this compose.yaml with mock providers added, a throwaway config and two throwaway environment files (one per container, as in a real deployment), publishes two local ports instead of 80 and 443 (18443, and 18444 for a second proxy with a closed admin allow-list), and checks it through TLS. Besides the functional checks it sends requests byte for byte through the real proxy: odd spellings of /admin and /internal paths, with and without credentials; malformed and oversized requests to both hosts, after which both containers must still be the same, never-restarted processes; that the data-plane container holds the internal token and no admin token; that both services have their stop timeouts and their 1 GiB memory limits; and that the data-plane container shuts down cleanly on SIGTERM. Three checks concern X-Forwarded-For through the proxy:

It waits for the control-plane container to report healthy; it does not look at the data-plane container's health status. Without --compose the same test uses plain docker commands (the stop-timeout and memory-limit checks then do not run, since they test compose.yaml). Needs a Linux Docker daemon and Python 3.9 or newer. CI runs it on every push.

From the README: tested with Docker 29 and Compose 2.40 on Ubuntu 24.04 (x86-64, under WSL 2). Not tested: ARM, Let's Encrypt issuance with a real domain, and Kubernetes.

Health and readiness probes

Process Probe Says
Data plane GET /healthz on its port, no key The process is up and answering. It does not say that a config has been loaded: a data plane that has never reached the control plane and has no cached config answers ok here and 503 gateway_not_ready to everything else.
Control plane GET /healthz, no credentials The process is up and answering.
Control plane GET /readyz, no credentials It can do its job: the config is loaded, the job store is running, the ledger is loaded and its file is writable. 200 {"status":"ready"}, or 503 {"status":"not_ready","reason":"..."} naming the first thing that is not.

Memory limits

compose.yaml gives both omnigate containers a memory limit (mem_limit). The proxy has none.

Variable in .env Default Limits
OMNIGATE_CONTROL_MEMORY 1g the control container
OMNIGATE_DATAPLANE_MEMORY 1g the dataplane container

The values are Docker sizes (512m, 1g, 2g). An .env without the two variables gets 1 GiB each. After a change, re-create the containers: docker compose up -d.

A container that grows past its limit is killed and, with restart: unless-stopped, started again, instead of taking the host down with it. Both processes come back cleanly from that:

What is lost is what any abrupt stop loses: the requests in flight, and what each process keeps only in memory (see Upgrades). A kill for memory is not a clean stop, so the data plane does not drain and the control plane does not write its closing snapshot.

The limit is a backstop, not the sizing. Size each process's own settings so that it stays under its limit; the kill is for what was not foreseen.

What the control plane needs memory for

What the data plane needs memory for

The budgets have to fit the limit, and under Compose they do. The binary's own defaults, 1024 + 1024 + 64 MiB, are 2,112 MiB: sized for a host without a memory limit, and about twice the 1 GiB the container gets. So compose.yaml sets the three itself, from .env:

Variable in .env Default under Compose Budget
OMNIGATE_REQUEST_MEMORY_MB 256 request bodies in flight
OMNIGATE_PARSE_MEMORY_MB 256 JSON trees
OMNIGATE_CACHE_MAX_MB 64 the response cache

That is 576 MiB of the 1,024, which leaves more than 400 MiB for the items without a setting. Under heavy or hostile load (many large bodies at once, from several keys) a budget then refuses a request, which costs one client a 429 or 503, before the container is killed, which would end every connection. (Until the close-out commit, the one after 55a294d, the Compose file left the budgets at the binary's defaults, and the container could be killed first.)

How much headroom is enough has not been measured: the repository records no figure for the data plane's memory under load beyond the per-request measurements in Operations, and none for the control plane as a whole beyond the 61 MiB per million ids. Watch the containers' actual use (docker stats) before you settle on values.

Without containers the same reasoning applies to whatever limit your service manager sets (a systemd MemoryMax, a cgroup, a ulimit): keep the data plane's two budgets plus its cache below it with room to spare, and give the control plane room for its ids and jobs. With no limit at all, the budgets are the only bound on the data plane, and nothing bounds the control plane's ids but retention.ledger_ids_days.

Running without containers

The same rules apply as with Compose: set both tokens, keep the control plane on a private address, and put TLS in front of both ports.

Data plane. Build an optimized binary and run it with its environment:

cd dataplane && zig build -Doptimize=ReleaseSafe

The result is dataplane/zig-out/bin/omnigate-dp. It needs OMNIGATE_INTERNAL_TOKEN, OMNIGATE_CONTROL_URL, the provider keys your text upstreams name, and a working directory it can write to (for the spool and config cache, or set OMNIGATE_SPOOL and OMNIGATE_CONFIG_CACHE to absolute paths). To accept connections from a proxy on another machine, set OMNIGATE_LISTEN to a non-loopback address. Stop it with SIGTERM (or Ctrl-C) so that it drains requests and delivers queued usage before it exits; under a service manager, allow it at least OMNIGATE_SHUTDOWN_GRACE_MS before a kill. omnigate-dp healthcheck, run with the same OMNIGATE_LISTEN, exits 0 when the instance answers /healthz, for a service manager that wants a command to probe with. If the service manager sets a memory limit, size the data plane's budgets to it (Memory limits).

Once a release has been tagged, prebuilt data-plane binaries for Linux (x86-64 and arm64, static), Windows and macOS are attached to it, each with a SHA-256 file; see Releasing. Only the x86-64 Linux binary is executed when a release is built; the others are cross-compiled and, like the platforms themselves, untested.

Control plane. The container image builds an Elixir release and starts it. The same steps on a host (this is what control/Dockerfile runs; it has not been tested separately outside the image):

cd control && MIX_ENV=prod mix deps.get --only prod && MIX_ENV=prod mix release
_build/prod/rel/omnigate_control/bin/omnigate_control start

Outside the image, set the paths explicitly, because the defaults are relative to the working directory: OMNIGATE_LEDGER, OMNIGATE_JOBS, OMNIGATE_KEYS, OMNIGATE_AUDIT_LOG, OMNIGATE_MEDIA_DIR. OMNIGATE_CONFIG is not optional here: a release build refuses to start without it, so that the development config with its published keys can never be the one in force on a server. The image also sets RELEASE_DISTRIBUTION=none so the release does not start Erlang distribution.

Give each process only its own secrets, as the Compose files do: the internal token to both; the admin token, media provider keys, webhook secrets and bucket credentials to the control plane only; text provider keys to the data plane only.

The proxy in front. Whatever you use, it has to do what deploy/Caddyfile does, and send request bodies with a length:

Timeouts to set against the gateway's. The proxy is the data plane's client, so the data plane's client timeouts apply to it:

The data plane After Consequence for the proxy
gives up on a client that stays connected but takes none of a response, the gateway's own answers included OMNIGATE_CLIENT_STALL_TIMEOUT_MS, 30 seconds A proxy that buffers a response, or stops reading because its own client is slow, is cut off after 30 seconds; the upstream is then read to its end without it and the call is billed. Raise the variable if your clients pause that long on purpose.
cuts off a response whose upstream sends nothing OMNIGATE_STREAM_IDLE_TIMEOUT_MS, 5 minutes This one no longer covers a client that stops reading. A proxy read timeout shorter than this ends streams the gateway would have waited for.
waits for an upstream's response headers OMNIGATE_UPSTREAM_TIMEOUT_MS, 10 minutes, or the upstream's timeout_ms A proxy response-header timeout shorter than this turns slow answers that are not streamed into the proxy's own error.
drops a client that is slow to send a request, and closes an idle keep-alive connection OMNIGATE_CLIENT_TIMEOUT_MS, 30 seconds A proxy that keeps idle connections to the gateway for longer will find them closed.

The included Caddyfile sets none of Caddy's timeouts. The variables are in Configuration.

Data-plane-to-control-plane traffic over HTTPS is untested; keep it on a private network. Platforms other than Windows 11 and x86-64 Linux containers are not verified.

Both processes refuse request paths that are not plain. The control plane answers 400 to any path with a percent-encoded character; the data plane answers 400 to any path that is not in its one canonical form (no %, no trailing slash, no empty, . or .. segment) and to a query string with characters outside a small set. Nothing either serves needs more, so the proxy needs no rule of its own for that; do not add one that rewrites or normalises paths on the way in. The rules are in API.

What to keep private

Thing Why
The control plane's port (4000) Dashboard, admin API, metrics and the internal API. Expose it only through a TLS proxy that hides /internal/*, and preferably only to operators (OMNIGATE_ADMIN_ALLOW with the Compose proxy).
OMNIGATE_INTERNAL_TOKEN Anyone holding it can read the config snapshot, write usage events and act as any gateway key on the jobs API. It also derives the file URL signing key unless OMNIGATE_SIGNING_SECRET is set.
OMNIGATE_ADMIN_TOKEN Dashboard, /admin/* and /metrics. Whoever holds it can create gateway keys and revoke existing ones.
.env.control Holds both tokens, media provider keys, webhook secrets and bucket credentials.
.env.dataplane Holds the internal token and the text provider keys.
.env Host names and the admin allow-list only. Not secret unless you kept secrets in it from an earlier commit.
The config file Holds gateway keys if you used "key" instead of "key_sha256", and webhook secrets if you wrote them inline.
keys.json Managed keys: hashes only, but a key's webhook secret in clear text if it has one. The control plane writes it with mode 0600.
audit.jsonl and its rotated files Who changed which key, who signed in and who failed to, with client addresses and the X-Forwarded-For value each request carried. No secrets.
The ledger files (ledger*.jsonl, ledger.snapshot*, ledger.ids.*), jobs.jsonl and the directory jobs.jsonl.inputs/ Usage per key, and every job's input (prompts), metadata and webhook URL. Inputs over 8 KiB are in the directory, one file per job. The id files hold only truncated hashes of request ids.
The data plane's log Key names, route names and request paths in the access log. Never keys, hashes or request bodies.
The media directory or bucket The generated files. Buckets are assumed private; clients get presigned URLs.
The data plane's port (8080) Not secret, but plain HTTP. Publish it only through TLS.

The three credentials do not work in each other's place: a gateway key is not accepted by the control plane, the admin token is not accepted by the gateway, and the internal token opens only /internal/*.

More in SECURITY.md and Security hardening.

Backups

What holds state that cannot be recomputed:

What Where (Compose) If lost
The usage ledger control-data: /data/ledger.jsonl, the sealed segments /data/ledger.NNNNNN.jsonl, /data/ledger.snapshot and /data/ledger.snapshot.prev, and the id files /data/ledger.ids.YYYY-MM-DD Spend totals are loaded from these at every start. Without any of them all spend is zero and budget-blocked keys are unblocked. What losing only some of them means is described below the table.
Job state control-data: /data/jobs.jsonl and the directory /data/jobs.jsonl.inputs/ Jobs, their idempotency keys, cache entries and pending webhooks are gone. Without the directory alone, every job whose input was over 8 KiB has lost that input.
Managed keys control-data: /data/keys.json Every key created through the dashboard or the admin API stops working: the gateway answers them with 401. The keys cannot be recovered (only hashes were ever stored); you create new ones and hand them out again. Their past usage is still in the ledger but no longer belongs to a named key.
The admin audit log control-data: /data/audit.jsonl, /data/audit.jsonl.1 to .3 The record of key changes and sign-ins is gone. Nothing else depends on it.
Receipts of answers from attested upstreams control-data: /data/receipts.json Earlier receipts answer 404. Nothing else depends on it; only present with an attested upstream.
Locally stored outputs control-data: /data/media/ Output URLs of existing jobs answer 404. Not applicable with bucket storage.
Your config and the three environment files (.env, .env.control, .env.dataplane) the repository directory You re-create them by hand. File keys come back with the config. Losing the internal token also invalidates outstanding signed file URLs, unless OMNIGATE_SIGNING_SECRET is set.

Back up and restore the ledger as one set of files, from one moment. The simplest rule is to treat the whole data directory as the unit.

Back up jobs.jsonl.inputs/ with jobs.jsonl. The directory is named after the job log (<OMNIGATE_JOBS>.inputs/) and holds the input of every job whose input is over 8 KiB as JSON, one file <job id>.json each; the job's line in the log only says that the input is in a file. A job log restored without the directory has jobs whose input is gone: a finished one shows "input": null, an unfinished one fails with internal_error when it is next run. An input file whose job is not in the log is deleted at the next start.

Restore keys.json together with the ledger from the same backup: the ledger records usage by key hash, and keys.json is what ties those hashes (including the ones from before a rotation) to a key and its budget.

The data plane's volume holds only usage events not yet delivered and a copy of the last config; once the spool has drained there is nothing in it worth keeping. The response cache and the refusal counts are in memory only.

A simple way to take a consistent copy is to stop the control plane, copy the volume, and start it again. While it is stopped the gateway keeps serving text requests and spools their usage; job requests answer 503 (see Operations).

docker compose stop control
docker run --rm -v omnigate_control-data:/data:ro -v "$PWD":/backup alpine tar czf /backup/control-data.tgz -C /data .
docker compose start control

The volume name is prefixed with the Compose project name (the directory name by default); check it with docker volume ls. This procedure is ordinary Docker practice, not something the repository's tests exercise.

The ledger's active segment, the job file and the audit log are appended to and synced to disk on every write (the job file is also rewritten compacted at start, after a retention sweep, and from time to time while running). Two things are written without a sync of their own: the first audit line of a failed or throttled sign-in, and the ledger's id files, which are synced when a snapshot is taken and cut back to the snapshot's length at a start. A job's input file is written and synced before the job that points at it. On start, a torn last line of the active segment is cut off, a torn last line of the job file is skipped, and unreadable lines of the audit log are skipped too; none of them is treated as fatal. keys.json is different: it is replaced whole on every change (temporary file, sync, rename), so a copy taken at any moment is complete; and if it exists but cannot be parsed, the control plane refuses to start rather than run without the managed keys.

Outputs in a bucket are not backed up by the gateway. With retention on (jobs_days), the gateway deletes a removed job's objects from the bucket unless retention.delete_bucket_objects is false; see Operations. Back them up with the bucket's own tools.

Upgrades

The only tagged version so far is the pre-release 0.1.0-rc.1, and compose.yaml builds both images from source, so an upgrade is a rebuild from newer source:

git pull
docker compose up -d --build

The repository has a release workflow that, when a version tag is pushed, publishes multi-architecture images to GHCR (ghcr.io/<owner>/omnigate-dataplane and -control, tagged with the version and latest) and data-plane binaries on a GitHub release; see Releasing. It has not run yet. Once it has, you can pin image: to a version in your own copy of the Compose file instead of building.

Read CHANGELOG.md and Upgrading first. Upgrading says, for the two security commits of November 2026 (e4e30e4 and d79406b), what changes on disk, which defaults changed, which config values that used to load are now rejected, what clients and operators will see, and how to go back. In short:

Coming from a commit before 3a0b147

Three security commits (97cf396, fde530c, 3a0b147) changed things an existing installation has to act on. What they fixed is in SECURITY.md. The steps below still apply; where a later commit changed a detail again, the step gives the current behaviour. Then go on to Upgrading for the two commits after them.

  1. Split the environment file. compose.yaml no longer reads secrets from .env; it reads .env.control and .env.dataplane, and Compose will not start a service whose file is missing. Copy the two .example files, move each variable from your old .env into the file of the process that reads it (step 2), and leave only the host names (and OMNIGATE_ADMIN_ALLOW, if you want one) in .env.
  2. Check the admin token's length. A token shorter than 16 characters now stops the control plane from starting.
  3. Rotate, if the control plane was reachable. If its port, or the dashboard's host name, could be reached by anyone you do not trust before the update: set a new OMNIGATE_ADMIN_TOKEN, delete managed keys you did not create, and rotate the gateway keys that were created or rotated in that time. No endpoint ever returned the internal token, so none of these fixes requires a new one; if you change it anyway, change it in both files and restart both containers together, and expect outstanding signed file URLs to stop working unless OMNIGATE_SIGNING_SECRET is set. The full list is in the advisories.
  4. Running without containers: a control-plane release now needs OMNIGATE_CONFIG.
  5. Clients may see new refusals. The gateway now answers 400 to a path with a percent-encoded character, a trailing slash or a . segment, to a query string with characters outside a small set, and to JSON nested deeper than 64 levels; and 411 to a POST without a Content-Length, which since commit e4e30e4 includes one with a chunked body. An Idempotency-Key is checked on job requests only, where it may be 1 to 255 visible ASCII characters without spaces (for a few commits it was checked on every endpoint and limited to letters, digits and -._:). Job creation can answer 429 too_many_open_jobs, and a job with a negative duration 400. See API.
  6. Bills can change. A stream the client abandons is now billed (the provider's real figures when they arrive within OMNIGATE_ABORT_DRAIN_MS, two minutes by default, and OMNIGATE_ABORT_DRAIN_MAX_MB; an estimate otherwise), and media jobs priced per second or per character are priced from what is sent to the provider. A job on a route priced per second that names no duration is refused unless the target has default_seconds. See Operations.
  7. Duplicate-detection ids now expire after 3 days by default (retention.ledger_ids_days; the default was 14 days from commit 3a0b147 until d79406b, and before that ids were kept for ever). The first retention sweep after the update drops older ids. Set a higher value if a data plane can hold undelivered usage for longer than three days, or 0 to keep the old behaviour of never forgetting.
  8. Settings are read strictly (commit 10bc009). A numeric environment variable with anything but digits in it, or an OMNIGATE_AUDIT_MAX_MB that is not a number, now stops the process at startup with a message that names it, where it used to be half-read or replaced by a default. Check your environment files before the restart.
  9. A proxy of your own no longer needs a rule for /v1/files/*. For one commit the included Caddyfile added the download headers to stored files; the gateway now sends them itself. If you copied that rule, it does no harm, but it is not needed.

What the code does during a restart:

The README lists the remaining windows: a crash between a provider accepting a job and its id reaching disk, or during a synchronous provider call, resubmits that job.

Keep a backup of control-data from before the upgrade. The repository has no migration tooling for the ledger or job files.

This page on GitHub