No description
  • Elixir 97.6%
  • Shell 1.2%
  • HTML 0.4%
  • JavaScript 0.4%
  • CSS 0.2%
  • Other 0.1%
Find a file
renovate[bot] 8579fb44aa
Some checks failed
Tag release / tag (push) Successful in 9s
Release / release (push) Failing after 7s
chore(release): bump version to 1.3.1 (#48)
2026-11-02 06:09:35 +00:00
.github/workflows chore(deps): update actions/checkout action to v7 2026-10-23 05:01:02 +00:00
assets feat(auth): add two-factor authentication (#16) 2026-10-15 18:08:30 +00:00
config feat(admin): add temporary admin access (break-glass) (#42) 2026-10-22 14:46:13 +00:00
dev/traefik feat(health): add health checks and maintenance page (#12) 2026-10-15 18:07:55 +00:00
lib feat(admin): manage system administrators (#43) 2026-10-22 14:50:59 +00:00
priv feat(admin): manage system administrators (#43) 2026-10-22 14:50:59 +00:00
rel feat(kubernetes): add probes and pod clustering (#34) 2026-10-21 13:54:01 +00:00
scripts feat(admin): add system administration panel (#39) 2026-10-22 13:23:05 +00:00
test feat(admin): manage system administrators (#43) 2026-10-22 14:50:59 +00:00
.dockerignore ci(images): publish release and development images (#26) 2026-10-20 18:58:10 +00:00
.formatter.exs chore: scaffold phoenix application (#1) 2026-10-13 16:15:36 +00:00
.gitignore chore: scaffold phoenix application (#1) 2026-10-13 16:15:36 +00:00
.tool-versions chore: scaffold phoenix application (#1) 2026-10-13 16:15:36 +00:00
AGENTS.md feat: add multi-tenant routing for Traefik nodes (#2) 2026-10-15 18:06:05 +00:00
compose.yaml feat(resources): add TCP and UDP resources (#4) 2026-10-15 18:06:43 +00:00
Containerfile chore(deps): update docker.io/library/debian docker tag to trixie-20261027-slim (#47) 2026-10-28 05:10:50 +00:00
Containerfile.dev chore: scaffold phoenix application (#1) 2026-10-13 16:15:36 +00:00
mix.exs chore(release): bump version to 1.3.1 (#48) 2026-11-02 06:09:35 +00:00
mix.lock chore(deps): update bandit and audit dependencies in ci (#24) 2026-10-15 19:25:58 +00:00
README.md feat(admin): manage system administrators (#43) 2026-10-22 14:50:59 +00:00
renovate.json ci: test pull requests only, require up to date (#36) 2026-10-21 14:27:12 +00:00

Traefik Control

A self-hosted, multi-tenant control plane for Traefik. One web UI manages the routing of many Traefik instances for many organizations, with strict isolation between organizations.

How it works

Each managed Traefik instance (a "node") is configured with Traefik's HTTP provider pointing at /api/traefik/v1/config on Traefik Control, authenticated with the node's bearer token. Traefik polls the endpoint; Traefik Control renders the node's routers, services and middlewares from the database. No agent runs next to Traefik.

 organization ──┬── nodes ───────────── Traefik instances poll their own config
                ├── resources ──┬─ domain (Host rule), TLS on/off
                │               ├─ targets (http / https / h2c, load-balanced, optional path routes)
                │               └─ published on selected nodes
                └── members (admin | member) and invitations

Multi-tenancy

  • A user can belong to any number of organizations, with a role in each (admin manages, member reads). Roles never carry over between organizations.
  • Every tenant-owned row references its organization, and every context function takes a Scope that carries a membership-verified organization. Cross-tenant ids resolve to "not found"; a non-member and a missing organization are indistinguishable.
  • Nodes belong to exactly one organization, so a node only ever receives its own organization's routes. Resources can only be published on the organization's own nodes.
  • System administrators create, suspend and delete organizations, and assign their admins, but get no implicit access to tenant data (see System administration).
  • test/traefik_control_web/tenancy_test.exs walks every page, id and endpoint from the outside; extend it whenever a tenant-owned surface is added.

Accounts and onboarding

Email and password (Argon2), or an OpenID Connect identity provider. Email delivery is optional; with it, addresses are verified.

  • The first account to register becomes a system administrator.
  • After that, set TRAEFIK_CONTROL_SIGNUP:
    • invite_only (default): system administrators create organizations; people join through invitations.
    • open: anyone can register and create their own organization (system administrators create theirs in System administration instead).
  • Organization admins invite by email address and role. The invitation is a single-use link shown once (copy it to the invitee), valid for 7 days, and usable only by that email address.

Email verification

With an SMTP server configured, people cannot log in until they confirm their email address (TRAEFIK_CONTROL_EMAIL_VERIFICATION=required, the default then; off switches it off, and required without SMTP refuses to start).

  • Whoever logs in with valid credentials but an unconfirmed address is mailed a link (at most one a minute) instead of getting a session. A wrong password sends nothing, so the login form cannot be used to mail strangers.
  • The link opens a page with a Confirm button; the address is confirmed when it is pressed, so mail scanners and link previews cannot use the link up. It works once, for 3 days, and only while the address is still the one it was sent to.
  • Confirmed from the start: the first account of an instance, accounts created by an identity provider (which verified the email), and every account that existed when verification was introduced. Invited and self-registered accounts are not: an invitation link proves nothing about the mailbox.
  • Turning verification on later also ends the sessions of accounts that were never confirmed; they confirm at their next login. If the mail server is down, set TRAEFIK_CONTROL_EMAIL_VERIFICATION=off to let people in.

Two-factor authentication

Anyone can add a second factor under Settings, Two-factor authentication (it needs a recent login):

  • an authenticator app (TOTP, RFC 6238), set up with a QR code and confirmed with a first code;
  • security keys and passkeys (WebAuthn), up to 10;
  • recovery codes (10, single use, shown once when the first factor is added, replaceable at any time).

Once a user has a factor, every way of logging in asks for it: password, identity provider, and the confirmation of a new password. Until it is given the login only waits in the session (10 minutes) and grants no access. A code works once (also against two requests racing with it), one step of clock drift is allowed, and 5 wrong answers lock the second step for 10 minutes, even for the right code. Adding the first factor ends the user's other sessions. Removing the last factor removes the recovery codes. The authenticator secret is stored encrypted; recovery codes only as hashes.

WebAuthn is bound to the address people use: the credential's relying party is the endpoint's host (PHX_HOST) and its origin the endpoint's URL, so serve the application on that address (https, except localhost). With PHX_SCHEME=http on another host browsers refuse WebAuthn, and the security keys section says so instead of offering to add one (see Running over plain HTTP). Attestation is not requested, and a signature counter that goes backwards is refused. A user who loses every factor and every recovery code needs an operator to remove their factors from the database (user_totps, webauthn_credentials).

Signing in with an identity provider (OpenID Connect)

System administrators add providers (Keycloak, Entra ID, Okta, Google, Authentik, ...) under System administration, Identity providers in the navigation. They apply to the whole server; a provider's page shows the redirect URI to register with it (<base URL>/auth/oidc/<short name>/callback). Each provider has:

  • the issuer, client ID and secret (kept encrypted, never shown again; leave it blank for a public client that relies on PKCE alone);
  • the email domains whose owners may create an account through it, and whether new accounts are created at all (off by default);
  • optionally an organization (and role) that new accounts join, recorded in that organization's audit log as member.joined.

The sign-in is the authorization code flow with PKCE (S256), state and nonce; the ID token's signature, issuer, audience, expiry and nonce are checked, and the provider's certificate is always verified. People are recognized by the provider's stable subject (sub), never by email. The rules, in order:

  1. A known identity signs in its user.
  2. A user who is signed in (and recently authenticated) can link a provider in their account settings and unlink it again, as long as another way to sign in remains.
  3. A new account is created only if the provider allows it, says the email is verified, and the domain is one it lists. It has no password until the person sets one in their settings.
  4. An email that already has an account is refused, never linked automatically: otherwise whoever controls a provider could take over any account by claiming its email. The owner logs in with their password and links the provider.

The very first account of an instance is always created by registering. Provider URLs must be https; set TRAEFIK_CONTROL_OIDC_ALLOW_HTTP=true only for a provider on a trusted network or in development. The provider's discovery document must publish the usual endpoints and signing keys; scopes_supported is optional.

What a provider publishes (its discovery document and signing keys) is cached, per issuer, for as long as the provider allows, between one minute and one hour, and fetched by one request at a time however many people sign in at once. A provider that fails is not asked again for 30 seconds, and a copy that worked before keeps sign-in going through its outage. A token signed with a key the cache does not have (the provider rotated its keys) fetches the keys again, at most once a minute. Editing or removing a provider forgets what was cached.

A system administrator who signs in through a provider needs no second factor of the application's own to change the server: the provider is trusted to enforce one (see System administration below). Signing in again for that purpose sends the provider prompt=login and max_age=0, so it asks for credentials instead of answering from its own session, and the ID token's auth_time, when present, must be recent.

System administration

System administrators (the server_admin flag of a user) have a page of their own, System administration in the navigation, at /admin. Nobody else can open it, and nothing on it shows tenant data.

  • Overview: the version, and the commit and build time of the image (the project's image scripts pass them as build arguments; a build made by hand shows unknown), Elixir/OTP and database backend, uptime; the health of the database and of each background service (node monitor, audit pruner, identity provider discovery, GeoIP loader, alert and mail delivery) and of email, GeoIP and identity providers; the number of nodes online, offline and never seen, on the server and per organization (counts and organization names only); and the Erlang VM's memory, processes and schedulers. It refreshes itself every 10 seconds.
  • Organizations: every organization with its status and counts of members, nodes and resources, with search; metadata only.
    • Create one for the person who will administer it, by email. You do not become a member: an existing account is made its admin at once, anyone else is invited as admin, and the invitation link is shown once to pass on.
    • Assign or replace its admin, for an organization that lost its admin. An admin is only replaced when another one is left, so a replacement who has only been invited has to join first. Ordinary members stay with the organization's own admins.
    • Suspend it: its members cannot open it, its API keys stop working, its resources stop answering sign-ins, and its nodes are served an empty configuration with a success status, so its sites go down as soon as the nodes poll. (A refused poll would not do: Traefik keeps serving the last configuration it received.) Nothing is deleted; reactivating restores everything.
    • Delete it, by typing its slug. Everything it owns goes, including its audit log. Deleting also removes the nodes' tokens, so their polls are refused and Traefik keeps serving the last configuration it received: the sites stay up until those Traefik instances restart. To take them down, suspend first and wait until the nodes have polled.
    • Take temporary admin access, to repair it or help its admins: an ordinary admin membership that ends by itself after 60 minutes (TRAEFIK_CONTROL_BREAK_GLASS_MINUTES, 1 to 1440), for a reason you must give. Every member of the organization sees a notice on every page of it, naming who has access, until when and why; the organization's audit log and the server's record when it starts, when it ends and who ended it (member.break_glass_*). It needs what changing the server needs, cannot be taken on a suspended organization or by someone who already is a member, and never counts as one of the organization's own admins (its last admin still cannot be removed). It ends at the minute whether or not anything cleans it up: an expired membership counts as gone everywhere, a page left open re-checks on every event, and a background task deletes it and writes the audit event. You can end it early, and the organization's admins can remove it from their members page.
  • Settings: the configuration the server is running with, beside the environment variable that sets it. Read-only; secrets are never shown.
  • Identity providers, as above.
  • Administrators: who the system administrators are and whether each has a second factor (one without is read-only).
    • Add one by email. An account that has a second factor (an authenticator app, a security key, or a login linked to an identity provider that is on) is promoted at once; an account without one has to set it up first. Anyone without an account is invited: a single-use link shown once, valid for 7 days, that works whatever TRAEFIK_CONTROL_SIGNUP says. They create their account, and become a system administrator the moment they have a second factor too (within 7 days of accepting), which the page they land on and their two-factor settings tell them.
    • Demote one. The last system administrator cannot be demoted, and nobody can demote themselves; the check and the change are made while the administrators are locked, so two demotions at once cannot leave none.
    • Invitations are listed with where they stand and can be withdrawn.
  • Audit log: what system administrators changed about the server (creating, suspending and deleting organizations, assigning admins, editing identity providers), kept as long as the organizations' audit logs (TRAEFIK_CONTROL_AUDIT_RETENTION_DAYS). It is written in the same transaction as each change, so a change that cannot be recorded does not happen, and it never holds a secret. What concerns an organization's members (an admin added, a suspension) is also in that organization's own log.
  • Live dashboard (/admin/dashboard): Phoenix LiveDashboard, with its destructive actions off. It replaces /dev/dashboard.

Looking needs a system administrator's login. Changing anything also needs proof of a second factor and a login from the last 10 minutes. A system administrator without a second factor of their own, or whose login is older, sees the pages read-only, with a link to fix it; every change is checked again when it is made, not only when the page opens. A session signed in through an enabled identity provider counts as having a second factor, on the assumption that the provider enforces one. A password login does not, even when the account also has a provider linked, and neither does a session of a provider that has been turned off or removed. A provider is trusted this way whatever its own settings, so the changes a system administrator may make are only as protected as the weakest provider configured: add only providers that require a second factor.

Path routing and sticky sessions

Every target of an HTTP resource can carry a route: which requests it receives, and how the path is rewritten before the request reaches it. A target without a route receives everything not claimed by a more specific one, so one domain can send /api/ to one service and the rest to another.

Match Rewrite (optional)
Prefix /api/ strip the prefix; replace it (/v1/ to /v2/); or set a fixed path
Exact /health set a fixed path (/status)
Regex ^/u/([0-9]+)$ regex replacement (/members/$1), or set a fixed path
  • Targets with the same match and rewrite are load-balanced together; targets on different routes are separate groups. Two targets on the same path with different rewrites are refused, since Traefik applies one rewrite per route. Routes cannot be edited in place: remove the target and add it again.
  • Exact paths win over regexes, which win over prefixes (longer prefixes first). Every route is also bound to the resource's own domain, so it can never match another tenant's traffic.
  • A prefix is a plain string prefix: /api/ matches /api/x but not /apix, while /api matches both. Use a trailing slash for a folder.
  • Regexes use Go's RE2 syntax (what Traefik runs). Lookarounds, backreferences, atomic groups and possessive quantifiers are rejected when saving.
  • The rewrite runs after access control: access rules (/admin* and so on) see the path the visitor asked for, not the rewritten one.
  • Disabling or removing a routed target sends its paths to the catch-all target, if there is one; without one they are no longer routed.
  • Paths may contain only URL path characters. A backtick or a space is refused, which is what stops a route from breaking out of its Traefik rule.

Sticky sessions (a checkbox on HTTP resources) make Traefik set a cookie so a visitor keeps reaching the same target, which matters for backends that keep session state in memory. The cookie is named tcs_<hash> (one per route group), HttpOnly, SameSite=Lax, and Secure when the resource is served over TLS. Visitors without the cookie are spread across the targets as usual. It does nothing with a single target, and it is not available for TCP and UDP resources.

Custom headers

HTTP resources can add headers to requests sent to the targets or to responses returned to visitors; a header with no value is removed instead. Names are stored in canonical case (x-source becomes X-Source), so one header cannot be defined twice, and a resource holds at most 50.

  • A request header replaces whatever the visitor sent under that name, so backends can trust it.
  • Setting Host on a request changes the host the backend sees, which suits backends that serve several sites; it does not change which resource matched.
  • Headers run after access control and before any path rewrite.
  • Values may not contain line breaks or other control characters, and the framing and hop-by-hop headers (Connection, Transfer-Encoding, Content-Length and so on) cannot be set. Remote-User and Remote-Email cannot be set on requests, because access control uses them to tell the backend who signed in.

Backend TLS

Targets that use https are checked against the system's trusted certificate authorities and the target's own host name. For backends with a private certificate, an HTTP resource has three settings (resource form, Backend TLS):

  • CA certificate: PEM certificates that Traefik trusts for this resource. The text is parsed and stored re-encoded, so only the certificates are kept; private keys are refused.
  • Server name: the name the backend's certificate must be valid for, when it differs from the target's host (for example when targets are IP addresses).
  • Do not verify the backend's certificate: accept any certificate. This removes the protection TLS gives against someone between Traefik and the backend, so it cannot be combined with the other two settings, and the resource page flags it.

Against a backend with a certificate from a private CA we saw: no settings fail (Traefik logs certificate signed by unknown authority), the CA alone works, the CA with a matching server name works, the CA with a server name the certificate is not valid for fails (certificate is valid for ..., not ...), and skipping verification works. Failures reach visitors as HTTP 500.

A resource whose stored settings are invalid (rows written around the application) is not served at all instead of falling back to weaker verification. The settings apply to every https target of the resource. They are not available for TCP and UDP resources. Certificate expiry is not checked when the CA is saved.

Health checks and maintenance

Both are settings of an HTTP resource (not available for TCP and UDP).

Health check. Give a resource a path (/healthz), an interval (5 to 3600 seconds) and a timeout (shorter than the interval) and Traefik probes every target of it, taking a failing one out of rotation until it passes again. With no healthy target Traefik answers 503.

Knowing which targets are up. Traefik does not answer questions about itself, so a node can instead push its health to the control plane over OTLP: add the metrics.otlp block shown on the node's page to its static configuration (the same token as the connection). The resource page then shows each target as up, down or no report, live, and the API has it as health on every target.

  • A node can only report on resources that are assigned to it and belong to its own organization; everything else in a report is ignored. Reports are read strictly and with limits (2 MB, 16 MB once decompressed, 5000 servers); one that cannot be read is refused as a whole.
  • A target is down if any node that reported lately says so, up if all do, and no report when none did in the last five minutes (no health check, metrics not set up, or the node went quiet).
  • Only the traefik_service_server_up metric is read. It is decoded straight from the OTLP wire format against a real capture of Traefik's export, so no protobuf library is needed.

Maintenance page. A resource's maintenance mode is:

  • off (default): normal.
  • automatic: the resource works as usual, and when Traefik answers 502, 503 or 504 (including "no healthy target") the visitor gets the maintenance page instead, still with that status.
  • on: every request for the host gets the page (503, Retry-After) and the targets are not used at all. Access control and headers are not applied either, so the page is the same for everyone.

The page is served by the control plane, at the address the node itself reaches it at (TRAEFIK_CONTROL_AUTH_URL when set), and is public: it shows the domain the visitor asked for and the message you wrote, nothing else (not the resource's name). It carries no assets, because Traefik answers every path of the host with it. Fail closed: without a usable address a resource in on mode is not served at all rather than exposed; automatic simply has no page then.

Both settings are in blueprints (health_check: {path, interval, timeout}, maintenance: {mode, message}) and the API (health_check_*, maintenance_*).

Alerts

Organization admins add alert channels under Alerts on the organization page, each with the events it wants:

Event When
node.offline / node.online A node has not polled for TRAEFIK_CONTROL_NODE_OFFLINE_AFTER seconds (default 180), and when it polls again
target.down / target.up A target fails its health check, and when it passes again (needs the node to report health, see above; a target first seen down also counts)
  • Webhook: a POST of JSON (id, type, occurred_at, organization, data) to a URL. Every request carries x-traefik-control-timestamp and x-traefik-control-signature: v1=<hex>, the HMAC-SHA256 of "<timestamp>.<body>" keyed with the webhook's secret. Recompute it, compare in constant time and reject old timestamps. The secret is generated here, shown once and stored encrypted with a key derived from SECRET_KEY_BASE (signing needs the secret itself, so it cannot be a hash); changing SECRET_KEY_BASE makes stored secrets unreadable, and New secret replaces one. The body is generic JSON: put an adapter in front for chat tools that expect their own format.
  • Email: plain text to an address the organization lists (no account needed, addresses are not verified). It needs an SMTP server, configured by the server's operator (see Configuration); without one, only webhooks can be added.
  • Delivery is in the background and best effort: a failed delivery is tried again after 5 and then 30 seconds, an attempt in flight is lost if the server restarts, and a channel that keeps failing shows it on the Alerts page (the outcome of the last delivery, with a short reason). Test sends one now. An outage of many targets at once alerts the first 20 of them. An alert is sent on every change, so a target that flaps alerts every time it does.
  • Webhooks are requests this server makes, so a destination that is private, loopback, link-local, or otherwise not on the open internet is refused, when the channel is saved and at every delivery, and a name that also resolves to such an address is refused too. The request goes to the address that was checked (with the host name kept for the Host header and TLS), so a name cannot change its answer in between. Redirects are not followed, an attempt gives up after 5 seconds, and what a receiver answers is never read or kept. TRAEFIK_CONTROL_WEBHOOK_ALLOW_PRIVATE=true lifts the address check, for an operator who trusts the organizations and the network.
  • Channels are managed by admins signed in as users only: not through the API, so a leaked API key cannot redirect an organization's alerts. Changes are audited, naming a webhook's host but never its URL (which is often a secret) and no email address. At most 20 channels per organization.

Access control

A resource is public until you protect it from its Access page (organization admins). Protecting it adds a Traefik forwardAuth middleware that asks the control plane about every request.

  • Rules are checked first, lowest priority number first, and the first match decides: allow skips sign-in, deny refuses with 403. They match on client IP, CIDR, path (exact, or a prefix ending in *), HTTP method, country and AS number (see below).

  • Country and AS rules match where the client is: country (a two-letter code such as DE), country_not (any country but that one) and asn (AS13335). They need a GeoIP database in the MaxMind DB format, which you provide: nothing is bundled and nothing is downloaded (MaxMind GeoLite2 and GeoIP2, DB-IP and ipinfo files all work, and so does a City database for countries).

    TRAEFIK_CONTROL_GEOIP_COUNTRY_DB=/data/GeoLite2-Country.mmdb
    TRAEFIK_CONTROL_GEOIP_ASN_DB=/data/GeoLite2-ASN.mmdb
    

    The files are read at startup and again whenever they change on disk (checked every minute, TRAEFIK_CONTROL_GEOIP_RELOAD_SECONDS), so refreshing them with geoipupdate or a cron job is enough; a file that cannot be read leaves the loaded database in place. The Access page shows which databases are loaded. A country rule cannot be created while its database is missing. A client that cannot be placed (an address that is not in the data, a private address, no database loaded) is treated so that it fails towards protection: it matches no country or asn rule; a deny rule for country_not matches it (so "deny everyone outside Germany" also refuses what nobody can place, and every client if the database is gone), and an allow rule for country_not never does (sign-in is never skipped on a guess). Blueprints use match: country, country_not or asn, with text values (AS13335, not a bare number). The location of a client is only as good as the data, and as the client address Traefik reports (see TRAEFIK_CONTROL_TRUST_PROXY): VPNs and proxies hide it.

  • Sign-in methods, any one of which lets a visitor in: single sign-on for members of the resource's organization, a shared password, a shared 6-digit PIN, and one-time codes by email. Share links grant access without an account and can expire or be revoked.

  • Codes by email need a mail server (see Configuration) and a whitelist of who may ask for one: addresses (alice@example.com) and whole domains (@example.com, which does not include subdomains). A visitor enters their address, gets a six-digit code that works once for 10 minutes, and types it in. The page answers the same for an address that is not on the list, and sends nothing to it, so the form cannot be used to mail strangers or to find out who is on the list. Requests are throttled (3 codes per address, 10 requests per client, 50 per resource in 15 minutes), a code dies after 5 wrong tries, and codes are stored only as keyed hashes. Removing an address from the whitelist, or turning codes off, ends its sessions. Blueprints carry the whitelist as access.email_codes (a non-empty list turns codes on).

  • A visitor who needs to sign in is sent to the control plane's login page (/auth/resource/:id), then back to the resource with a one-time token that is swapped for a session cookie scoped to the resource's own domain. API clients get a 401 with the login URL instead of a redirect.

  • SSO sessions pass Remote-User (user id) and Remote-Email to the backend; sessions made with an emailed code pass Remote-Email only. Traefik removes any copy of those headers sent by the client.

  • Sessions are re-checked on every request: removing a member, changing a password or PIN, disabling a method, or revoking a share link ends the matching sessions immediately. Wrong passwords and PINs are throttled per client and per resource.

Things to know:

  • Deny rules ignore case; path rules match on a canonical path (percent escapes decoded, .. and // resolved), and a request whose path needed normalizing never matches an allow rule.
  • A protected resource is never served unprotected: if the control plane cannot give Traefik a usable auth address, the resource is not served at all. By default the address is derived from the URL the node polls; set TRAEFIK_CONTROL_AUTH_URL when Traefik reaches the control plane under a different address.
  • The session cookie is also sent to the backend (forwardAuth cannot strip it).
  • Rules see the address that connects to Traefik. Behind a CDN or another proxy that is the proxy's address unless Traefik is configured to trust it.
  • Share links redirect to scheme://domain/. If the resource is not on the default port, append ?redirect= with the full URL to land on.
  • Set TRAEFIK_CONTROL_TRUST_PROXY=true only when the control plane itself is behind a reverse proxy you control; login throttling then uses the last X-Forwarded-For entry instead of the proxy's address.

TCP and UDP resources

A resource can also carry raw TCP or UDP traffic (databases, DNS, game servers, ...). Pick the type when creating it (it cannot be changed later), give it a listen port, and add targets as plain host:port addresses.

  • Traefik matches raw traffic by entrypoint, and entrypoints are part of a Traefik instance's static configuration. By convention a resource on port 5432 uses the entrypoint tcp-5432 (udp-5353 for UDP). Each node's page lists exactly the entrypoints it needs, as a snippet to paste into that Traefik's configuration (:5432, :5353/udp); the ports must also be published by whatever runs Traefik.
  • By default a TCP resource owns its port: its router uses the catch-all HostSNI(*) and the optional hostname is only for your reference. A port is unique per organization and protocol, so TCP and UDP can both use 53. On a node, if two resources still claim the same port, the one with the lowest id is served and the other is skipped.
  • Routing by SNI: tick Route by TLS server name on a TCP resource and its hostname becomes the server name clients ask for. Any number of such resources can share a port (each with its own name, unique on the port), so several TLS services can sit behind one address. Traefik passes the TLS connection through unchanged: the target terminates TLS and needs a certificate for that name (Traefik Control does not terminate TLS for raw resources). One resource that is not routed by SNI may stay on the port as the default, for plain TCP, for names nobody claims, and for clients that send no name; without one, those connections are not served. Blueprints use sni_routing: true and match such resources by port and hostname.
  • There is no access control for raw resources: they have no Access page, and one that somehow carries access settings is not served at all rather than served unprotected. Restrict access to them at the network level.

Domains and wildcard certificates

Organization admins list the domains they own under Domains on the organization page and prove each one with a DNS record:

_traefik-control.example.com   TXT   "traefik-control-verification=<token>"

Check DNS looks the record up and marks the domain verified. The token is random and never reused, and only one organization at a time can hold a name as verified, so nobody can take over a domain another organization has proved (the second claim gets "already verified"). A domain can be checked once every 10 seconds. A verified domain covers itself and every name below it. Verification is a snapshot: it stays after the record is removed, until the domain is removed.

  • Requiring ownership: with TRAEFIK_CONTROL_REQUIRE_VERIFIED_DOMAINS=true, the name of an HTTP resource (or of a TCP resource routed by SNI) must lie under a verified domain of its organization. Only a name that is being set is checked, so switching this on does not cut off resources that already exist. It is off by default.

  • Wildcard certificates: tick the option on a verified domain and the routers for that domain and the names directly under it (example.com, app.example.com, not a.b.example.com) ask Traefik for one certificate (example.com plus *.example.com) instead of one per name. Wildcard certificates can only be issued with a DNS challenge, so the certificate resolver named for the domain (or else the node's) must be one in the node's Traefik static configuration that solves DNS challenges, for example:

    certificatesResolvers:
      dns:
        acme:
          email: you@example.com
          storage: /letsencrypt/acme.json
          dnsChallenge:
            provider: <your DNS provider>   # its credentials come from Traefik's environment
    

    Traefik Control only names the resolver in the dynamic configuration. It never holds DNS credentials, and cannot create the resolver: that is part of the node's own configuration, like its entrypoints. Without any resolver on the domain or the node, nothing is asked for.

  • Domains are in the API (/api/v1/domains, including POST .../verify) and the audit log. They are not part of blueprints, because they need a DNS record to be created.

API and API keys

Organization admins create API keys under API keys on the organization page: a name, an access level and an optional expiry (30 days, 90 days, a year). A key is shown once, stored only as a SHA-256, and revoking it takes effect immediately. Keys belong to the organization, not to a person, so they keep working when their creator leaves.

  • Read keys can use GET only. Write keys can also change nodes, resources, targets, headers, access settings, rules, share links and domains, remove members and apply blueprints, with the rights of an organization admin.
  • A key can never create, list or revoke keys: a leaked key cannot mint itself a successor. For the same reason, members cannot be added or promoted through the API (no invitations, no role changes): a key must not be able to grant a person access to the organization.
  • Keys have no connection to web sessions: being signed in does not authenticate an API call, and a key does not sign anyone in.
curl -H "Authorization: Bearer tck_..." https://control.example.com/api/v1/resources
Endpoint Purpose
GET /api/v1/organization The key's organization
GET, POST /api/v1/nodes; GET, PATCH, DELETE /api/v1/nodes/:id; POST .../nodes/:id/token Nodes. A token is shown only in the response that creates the node or replaces its token, never in a listing
GET, POST /api/v1/resources; GET, PATCH, DELETE /api/v1/resources/:id Resources, including their targets and headers
POST /api/v1/resources/:id/targets; PATCH, DELETE .../targets/:target_id Targets and their path routes
POST /api/v1/resources/:id/headers; DELETE .../headers/:header_id Custom headers
GET, PATCH /api/v1/resources/:id/access Authentication settings: sso, password, pin (text sets it, null removes it). Passwords and PINs are write-only; reads show password_set and pin_set
GET, POST /api/v1/resources/:id/rules; PATCH, DELETE .../rules/:rule_id Allow and deny rules, listed in evaluation order
GET, POST /api/v1/resources/:id/share-links; DELETE .../share-links/:link_id Share links. The link's token is shown only in the creating response
GET, POST /api/v1/domains; GET, PATCH, DELETE /api/v1/domains/:id; POST .../domains/:id/verify Domains, the record that proves them, and their wildcard certificate settings
GET /api/v1/members; DELETE /api/v1/members/:id Members; the last admin cannot be removed
GET /api/v1/audit-events The audit log, newest first: category, limit (1 to 100) and before (the next of the previous page)
GET /api/v1/blueprint The current configuration as a blueprint (YAML text in data.blueprint)
POST /api/v1/blueprints/plan, POST /api/v1/blueprints/apply Preview or apply a blueprint ({"blueprint": "<yaml>"}); write keys only
GET /api/v1/openapi.json The OpenAPI 3.1 description (public)

Responses wrap their payload in data. Every error is {"errors": {...}}: field names map to lists of messages (422), and detail is used for everything else (401, 403, 404). Records of another organization are 404, the same as ones that do not exist. Raw (TCP/UDP) resources have no access settings, rules or share links (422). Invitations, API keys and alert channels are not part of the API. The OpenAPI document is checked against the router and the responses by the test suite.

A resource created through the API is picked up by Traefik on its next poll, like one created in the web interface. In development, Phoenix answers a malformed JSON body with its debug page instead of the JSON error.

Audit log

Organization admins can read Audit log on the organization page: who changed what, and who signed in to the organization's protected resources.

  • Recorded: changes to members, invitations, nodes (including token regeneration), resources, targets, headers, access settings, rules, share links, API keys and alert channels, and sign-ins to protected resources, successful or not (password, PIN, SSO, share link). Each event names the actor (a user, an API key, or a visitor's address), the subject and what changed.
  • Atomic: an event is written in the same database transaction as the change it describes, so a change that cannot be logged does not happen, and a rolled back change leaves no event. A write that changes nothing leaves no event.
  • Secrets stay out: passwords and PINs appear only as [changed], and the values of custom headers (they can be credentials for the backend), backend CA text, tokens and hashes are never stored in events.
  • Not recorded: proxied requests (use Traefik's access log), attempts that were throttled, and SSO attempts by people who are not members, so that nobody can flood an organization's log. Wrong passwords and PINs are recorded until the throttle trips.
  • Visitor addresses are the connection address, like the login throttle: behind your own reverse proxy, set TRAEFIK_CONTROL_TRUST_PROXY, otherwise you will see the proxy's address (in the dev stack, the container network's).
  • Admins signed in as users can read the log in the web interface, and the organization's API keys (read keys too, so that a log collector needs no more than that) can read it through GET /api/v1/audit-events. Members cannot.
  • Events older than TRAEFIK_CONTROL_AUDIT_RETENTION_DAYS (default 90, 0 keeps them) are removed shortly after start and every six hours.

Blueprints

Organization admins can describe resources in a YAML blueprint and apply them at once from Blueprints on the organization page, or through the API. Preview shows what would be created and updated, or the problems, each with its path (resources[1].targets[0].host); Apply then does it, all or nothing. Load current configuration puts the organization's setup in the box, which is also the way to move it to another organization.

version: 1
resources:
  - name: Shop
    domain: shop.example.com   # HTTP resources are matched by domain
    ssl: true
    nodes: [edge-1]            # node names
    targets:
      - {host: 10.0.0.5, port: 8080}
    headers:
      response: {X-Frame-Options: DENY}
    access:
      sso: true
      rules:
        - {action: deny, match: path, value: /admin*}
  - name: Database
    protocol: tcp              # TCP and UDP are matched by protocol and port
    listen_port: 5432
    nodes: [edge-1]
    targets:
      - {host: 10.0.0.9, port: 5432}
  • Same rules as the web interface: a blueprint goes through the same validation as the forms (hostnames, ports, path routes, headers, backend TLS, rules), and each change is written to the audit log, attributed to the user or API key that applied it, together with one blueprint.applied summary.
  • Matching, not deleting: a resource that is in the blueprint is updated, one that is not is created, and resources that are not in the blueprint are left alone; a blueprint never deletes a resource. In a resource, a section you leave out (targets, headers, access, nodes, backend_tls) is not touched; one you include is declared in full, so targets, headers and rules you do not list are removed. Applying the same blueprint twice changes nothing and logs nothing.
  • No secrets: passwords, PINs and share links cannot be set by a blueprint and are never exported (keys like password are refused, not ignored). Header values and backend CA text are exported as they are, so treat an export like configuration, not like a public file.
  • Strict YAML: one document of at most 256 KB and 200 resources. Anchors, aliases, merge keys, tags, tabs, duplicate keys and unknown keys are errors rather than being guessed at, and it is read in a separate process with a memory and time limit.
  • API: plan and apply need a write key; GET /api/v1/blueprint works with a read key. Problems are a 422 whose errors map blueprint paths to messages.

Security notes

  • Node tokens and invitation tokens are random 256-bit secrets, stored only as SHA-256 and shown once. API keys follow the same rule.
  • Domains and target hosts are interpolated into Traefik rules and URLs. They are validated on write (TraefikControl.Hostnames) and validated again by the renderer, which drops anything unsafe.
  • The node endpoint and the API answer every authentication failure with the same 401.
  • Email addresses are verified when an SMTP server is configured (see Email verification); without one they are not, and an unverified address could be claimed by someone else in open mode before its owner is invited, so prefer invite_only. Addresses an identity provider has verified are the only ones it will create accounts for.
  • The WebAuthn support uses the wax_ library, which says of itself that it has not been reviewed by independent security specialists.

Stack

Component Choice
Language Elixir 1.20 / OTP 29
Web Phoenix 1.8 + LiveView 1.2, Bandit
Database SQLite (development, small setups) or PostgreSQL (recommended for production)
Proxy Traefik (latest image in the dev stack)

Versions of the tooling image are pinned in Containerfile.dev and .tool-versions. Third-party service images in compose.yaml use latest.

Configuration

Variable Purpose
TRAEFIK_CONTROL_DB Compile time. sqlite3 (default) or postgres.
DATABASE_PATH Production, SQLite: database file.
DATABASE_URL Production, PostgreSQL: ecto://user:pass@host/db.
SECRET_KEY_BASE Production: mix phx.gen.secret.
TRAEFIK_CONTROL_SIGNUP invite_only (default) or open.
TRAEFIK_CONTROL_AUTH_URL Base URL Traefik uses for forward-auth calls, e.g. https://control.example.com. Derived from the node's poll URL if unset.
TRAEFIK_CONTROL_TRUST_PROXY true when behind your own reverse proxy (login throttling uses X-Forwarded-For).
TRAEFIK_CONTROL_AUDIT_RETENTION_DAYS Days to keep audit events (default 90; 0 keeps them forever).
TRAEFIK_CONTROL_NODE_OFFLINE_AFTER Seconds without a poll before a node counts as offline and is alerted about (default 180, at least 90; 0 switches the monitor off).
TRAEFIK_CONTROL_WEBHOOK_ALLOW_PRIVATE true allows webhooks to private and local addresses (default off, see Alerts).
TRAEFIK_CONTROL_SMTP_HOST Enables email alerts. Needs TRAEFIK_CONTROL_MAIL_FROM (the sender, alerts@example.com or Name <alerts@example.com>).
TRAEFIK_CONTROL_SMTP_PORT, TRAEFIK_CONTROL_SMTP_USERNAME, TRAEFIK_CONTROL_SMTP_PASSWORD The mail server's port (default 587, or 465 with ssl) and credentials.
TRAEFIK_CONTROL_SMTP_TLS starttls (default), ssl or none. The server's certificate is always verified against the system's authorities and its name, except with none.
TRAEFIK_CONTROL_REQUIRE_VERIFIED_DOMAINS true requires resource names to lie under a verified domain (default off, see Domains).
TRAEFIK_CONTROL_GEOIP_COUNTRY_DB, TRAEFIK_CONTROL_GEOIP_ASN_DB Paths of MMDB files that enable country and AS rules (see Access control).
TRAEFIK_CONTROL_GEOIP_RELOAD_SECONDS How often those files are checked for changes (default 60; 0 never).
TRAEFIK_CONTROL_OIDC_ALLOW_HTTP true allows identity providers on plain http (default off, development only).
TRAEFIK_CONTROL_EMAIL_VERIFICATION required (default when SMTP is configured) or off.
POOL_SIZE Production: database connections per instance (default 10).
PHX_HOST Production: public host name (default example.com). Links (invitations, confirmations, the OIDC callback, node snippets) and the https redirect always use it, never a request's Host header.
PHX_SCHEME Production: https (default: plain HTTP is redirected to https://<PHX_HOST> and HSTS is sent, except /healthz, /readyz and localhost) or http (no redirect, no HSTS, links are http://). See Running over plain HTTP.
PHX_URL_PORT Production: the port in those links (default 443 with https, 80 with http; a scheme's default port is left out of them).
RELEASE_COOKIE, POD_IP Production: set both to cluster instances (see Kubernetes). Without both, Erlang distribution is off.
DNS_CLUSTER_QUERY A DNS name that resolves to every instance (a headless Service); instances with distribution on join each other through it.
DIST_PORT The Erlang distribution port of a clustered instance (default 9100).

Running over plain HTTP. With PHX_SCHEME=http the application serves plain HTTP as it is, for when it is only reached over a VPN (for example PHX_SCHEME=http PHX_HOST=control.vpn.example PHX_URL_PORT=4000 for http://control.vpn.example:4000) or behind a reverse proxy of your own that does not use https. Links and the node snippets then read http://<PHX_HOST>[:<PHX_URL_PORT>], so the nodes poll over plain HTTP, and the forward-auth address follows unless TRAEFIK_CONTROL_AUTH_URL is set. Session cookies, passwords and node tokens cross that network unencrypted, so use it only on a network you trust. Browsers allow WebAuthn only on https or localhost, so security keys cannot be added or used there; authenticator apps and recovery codes work. TRAEFIK_CONTROL_TRUST_PROXY is unrelated: set it only when a reverse proxy you control is in front, never when clients reach the application directly (VPN included).

Email uses Swoosh's SMTP adapter, which needs gen_smtp. Swoosh decides how to encode a message when it is compiled, so a checkout whose _build was made before gen_smtp was added fails every send with a BadMapError; run mix deps.compile swoosh --force once. A clean build is fine.

Ecto adapters are fixed at compile time, so the database backend is chosen when the application is compiled:

TRAEFIK_CONTROL_DB=sqlite3  mix compile   # default
TRAEFIK_CONTROL_DB=postgres mix compile

Each backend builds into its own _build/<backend> directory. Migrations and queries must stay portable across both backends; run the test suite against both. Development and test settings live in config/database.exs, production settings in config/runtime.exs.

Development

All tooling runs in containers; nothing is installed on the host.

podman build -f Containerfile.dev -t traefik-control-dev .

# One-off mix command (files stay owned by your user):
podman run --rm -it --userns=keep-id -v "$PWD":/work -w /work \
  traefik-control-dev mix test

# Full dev stack: app + PostgreSQL + Traefik + whoami backends (HTTP, TCP, UDP)
podman-compose up

The stack publishes on the loopback interface only:

Service URL
Traefik Control http://127.0.0.1:4000
Traefik dashboard http://127.0.0.1:8081
Traefik web / TLS 127.0.0.1:8000 / :8443
Raw TCP / UDP 127.0.0.1:9000 / :9001

The dev seed (priv/repo/seeds.exs, part of mix ecto.setup, :dev only) creates a ready-made tenant so the whole path works immediately:

  • login dev@example.com / dev-password-1234 (public, development only)
  • organization dev with a node whose token is in dev/traefik/traefik.yml
  • resource whoami.localhost routed to the whoami container
  • TCP resource on port 9000 and UDP resource on port 9001, routed to the whoami-tcp and whoami-udp containers (their entrypoints are declared in dev/traefik/traefik.yml)
curl -H 'Host: whoami.localhost' http://127.0.0.1:8000/
(printf 'WHO\n'; sleep 1) | socat -t2 - TCP:127.0.0.1:9000   # Name: whoami-tcp
printf 'WHO\n' | socat -t1 - UDP:127.0.0.1:9001              # Name: whoami-udp

When testing TCP resources by hand, keep the client's connection open until the reply arrives (as the sleep above does). Through Traefik, a client that closes its sending side right after writing (for example echo ... | nc) got no reply in our tests, although the same exchange works against the backend directly.

Continuous integration

.github/workflows/ci.yml runs on every pull request into main and, for the dependency audit, every Monday (Forgejo and Gitea Actions read .github/workflows; the same file works on GitHub). main is not tested again after a merge: the branch protection requires a pull request to be up to date with main and to pass the required checks, so what lands has been tested as it lands. It runs the checks that every change must pass, in the hexpm/elixir image Containerfile.dev uses:

  • format, compile, credo, dialyzer: mix format --check-formatted, mix compile --warnings-as-errors, mix credo --strict, mix dialyzer
  • tests (SQLite) and tests (PostgreSQL): mix test on each backend, then a migration round trip (mix ecto.rollback --all and mix ecto.migrate on an empty database)

A fourth job, dependency audit (mix hex.audit: security advisories and retired packages), runs on pull requests and every Monday. It is deliberately not a required check: an advisory published tomorrow should be visible, not block merges that have nothing to do with it.

main only accepts merges whose three jobs passed on a branch that is up to date with main: when another pull request merges first, update the branch (Renovate and the weekly release pull request do this themselves). The jobs need a runner with the ubuntu-latest label that can run job containers and service containers.

Container images and releases

The database backend is fixed when the application is compiled, so a release is two images, built from Containerfile (a mix release on Debian, running as the unprivileged user 65534):

Package Backend Needs
traefik-control-postgres PostgreSQL DATABASE_URL, SECRET_KEY_BASE
traefik-control-sqlite3 SQLite DATABASE_PATH, SECRET_KEY_BASE

Both are in the forge's package registry, linked to this repository (git.enclaveis.com/white_ghost_cyber_systems/traefik-control-postgres:<tag>). Tags:

Tag Names
0.1.0 A release; never replaced once published.
latest The highest release.
0.1.1-dev.abc1234 A development build of commit abc1234, a pre-release of the mix.exs version.
development The most recent development build of any branch.

The image starts the server (PHX_SERVER=true, port 4000) and runs migrations on boot. The SQLite image keeps its database in the /data volume, so set DATABASE_PATH=/data/traefik_control.db and mount a volume there. Build one locally with podman build --build-arg DB=postgres -t traefik-control .. The published images are built with --build-arg COMMIT=<sha> --build-arg BUILD_TIME=<UTC timestamp> (scripts/images.sh), which the system administration overview shows; a local build without them shows unknown.

Development builds. .github/workflows/development.yml runs scripts/development.sh on every push to a branch other than main, so a change can be run before it is merged. development is a single tag for the whole repository: it moves to whichever branch pushed last, so use the <version>-dev.<commit> tag to test a particular branch. Images of main are only published by releases.

Cleanup. .github/workflows/clean-development.yml runs scripts/clean-development.sh when a branch is deleted (merging a pull request deletes its branch), every Sunday, and on demand (a manual run is a dry run by default and only lists what it would delete). A <version>-dev.<commit> version is kept only while its commit is the head of a branch other than main, so the images of merged and deleted branches go, and so do the ones a later push of a live branch replaced. The untagged sha256: manifest versions the forge leaves behind are deleted as well, unless a remaining tag still refers to them (this is why development keeps working after its branch is gone, until the next development build replaces it) and they are older than an hour. Releases, latest and development are never deleted. The workflow needs the REGISTRY_TOKEN secret (write:package).

Releases follow jellyfin-sso-plugin: a bare version tag (0.1.1, no v prefix), semantic versioning, with version in mix.exs as the source of truth.

Releases are cut every week when files that ship have changed, and by hand when you want one sooner.

  • Weekly: .github/workflows/weekly-release.yml runs scripts/propose-release.sh every Monday (and on demand from the Actions tab). If commits since the latest release tag changed files that ship in the images, it opens a chore(release): bump version to <x.y.z> pull request that raises the version in mix.exs, lists those commits, and is set to merge once the required checks pass. Files that ship are what Containerfile copies into the build (lib, priv, assets, config, rel, mix.exs, mix.lock) and the Containerfile itself, except development and test only files (config/dev.exs, config/test.exs, priv/repo/seeds.exs). Changes to tests, documentation, CI, scripts and development tooling never trigger a release. If nothing that ships changed (or a bump is already open or ahead of the latest tag), it does nothing and the latest release stands.
  • Size of the bump: from the conventional commit types of those commits. A breaking change (type!: or a BREAKING CHANGE: footer) raises the major version, a feat the minor, and anything else (dependency updates, fixes, performance) the patch: 1.0.0 becomes 1.0.1, 1.1.0 or 2.0.0.
  • By hand: raise version in mix.exs in a chore(release): bump version to <x.y.z> pull request (a patch for fixes, a minor for features; the changelog is the commit history).

Either way, once that pull request is merged:

  1. .github/workflows/tag.yml runs scripts/tag-release.sh for the push to main: if version has no tag yet and is higher than every released version, it tags the merge commit. A push that leaves the version alone does nothing, and a tag you push yourself first (git tag -a <x.y.z> <commit> -m <x.y.z> && git push origin <x.y.z>) is simply found. The tag is pushed with RENOVATE_TOKEN, a real account's token, because a tag pushed with the job's own token may not start the next workflow.
  2. .github/workflows/release.yml runs scripts/release.sh on the tag, which refuses a tag that does not equal the mix.exs version or is not on main, builds both images, pushes them (a version already in the registry is never replaced, so a re-run after a failure is safe), moves latest onto them when the tag is the highest release (an older point release never takes it), links each package to this repository as the helm repository does, and creates the release with the commits since the previous tag.

The image workflows need a REGISTRY_TOKEN repository secret with the write:package scope (a token limited to repositories cannot hold package scopes, so it carries nothing else), and a runner with a container engine (docker) on ubuntu-latest. The release workflow reads the repository and creates the release with the job's own token (github.token). PUSH=0 ENGINE=podman scripts/development.sh <branch> or scripts/release.sh <x.y.z> (on the tag) builds both images without publishing anything.

Kubernetes

The release images run well under Kubernetes as they are; the Helm chart for them is in the White_Ghost_Cyber_Systems/helm repository (charts/traefik-control). What the application provides for it:

  • Probes. GET /healthz answers 200 while the process serves requests (liveness, startup); GET /readyz answers 200 when the database answers a query within two seconds and 503 otherwise (readiness). They are plain HTTP to the pod address, exempt from the https redirect, and are not logged. Do not use /readyz for liveness: a database outage would restart every pod.
  • Migrations run on start, before the port opens (give the startup probe time), and are serialized by the database, so several pods can start at once.
  • Several replicas need PostgreSQL. Nothing else is held in one pod's memory that another needs: login and two-factor throttling, target health and the audit log are in the database, the node monitor claims each online/offline change with a conditional update so exactly one pod alerts, and audit pruning is idempotent. Caches (GeoIP files, identity provider discovery) are per pod.
  • Clustering. With RELEASE_COOKIE and POD_IP set the pods start Erlang distribution and find each other through DNS_CLUSTER_QUERY; a change made through one pod then reaches the browsers connected to another (LiveView updates). Without them the pods run alone and everything above still holds, except that live updates stay within the pod. Pods must reach each other on epmd (4369) and DIST_PORT. Distribution is off unless you supply the cookie: the image carries a default cookie that anyone could read, and a node with an open distribution port and a known cookie runs code for whoever connects. bin/traefik_control rpc and remote therefore need RELEASE_COOKIE and POD_IP as well.
  • Read-only root filesystem. The image runs as uid 65534 and writes only to /tmp (RELEASE_TMP) and, for SQLite, /data; mount an emptyDir on /tmp. Tested with --read-only --tmpfs /tmp --cap-drop ALL.
  • Shutdown. The process stops on SIGTERM; give the pod a short preStop sleep so the load balancer stops sending requests first.

Dependency updates

.github/workflows/renovate.yml runs a pinned, self-hosted Renovate every day (and on demand from the Actions tab); renovate.json holds the rules. It opens pull requests for the Hex packages (and mix.lock), the esbuild and tailwind versions in config/config.exs, the Debian image the release runs on, GitHub actions and the Renovate image itself. Renovate's branches are named chore/deps-* and do not build development images.

  • Automerge: patch and minor updates are merged by the forge once the three required checks on main pass (squash), after the release has aged 3 days so a pulled or hotfixed release is skipped. Major updates stay manual. The Debian image tag is a date, so its "major" (the year) is merged as well.
  • Releases: merged updates wait for the weekly release (Container images and releases above), which bumps only the patch version when dependency updates are all that changed; Renovate does not raise the version itself, so several updates in a week make one release. Updates that touch no shipped file (GitHub actions, the Renovate image) do not cause a release.
  • Not managed: the hexpm/elixir toolchain image and .tool-versions move together with the CI cache keys and Dialyzer PLTs, so upgrading Elixir/OTP is a deliberate change; the latest third-party images in compose.yaml and the CI PostgreSQL service need no updates.
  • Branch protection: automerge relies on the required checks of main; if they are removed, Renovate's pull requests merge unchecked.
  • Setup: the workflows need a RENOVATE_TOKEN repository secret, the token of a dedicated account with write access to this repository only (repository and issue scopes), not a personal token; tag.yml uses it to push release tags. An optional RENOVATE_GITHUB_COM_TOKEN secret (a github.com token with no scopes) avoids rate limits when looking up GitHub-hosted dependencies (Renovate reports github-token-required for them without it).

Connecting your own Traefik

  1. Log in, create an organization (system administrators) and a node under Nodes. Copy the token; it is shown once.

  2. Add the snippet shown on the node page to that Traefik's static configuration:

    providers:
      http:
        endpoint: "https://control.example.com/api/traefik/v1/config"
        pollInterval: "5s"
        headers:
          Authorization: "Bearer <node token>"
    
  3. Create a resource, add targets, and tick the node. The route appears in Traefik on its next poll (within 5 seconds).

  4. Optional, to see target health: add the metrics.otlp snippet from the node page next to it (see Health checks and maintenance above).

The node's HTTP/HTTPS entrypoint names and certificate resolver must match its Traefik static configuration. With TLS enabled, a resource is served on the HTTPS entrypoint and HTTP requests are redirected to it.

Checks run before every commit: mix format --check-formatted, mix compile --warnings-as-errors, mix credo --strict, mix dialyzer, mix test (on both backends).

Status

Implemented

  • Multi-tenant organizations, memberships, roles, invitations
  • Multi-node management: per-node bearer-token endpoint, online status
  • HTTP resources with load-balanced targets (http, https, h2c), TLS with an optional certificate resolver, HTTP to HTTPS redirect, per-node publishing
  • TCP and UDP resources on their own ports, with the entrypoints each node needs listed in the UI; several TCP services can share a port by TLS server name (SNI, TLS passthrough)
  • Path routing (prefix, exact and regex matches) with path rewrites, sticky sessions, custom request and response headers, and backend TLS options (custom CA, server name, skip verification)
  • Health checks with target status reported by the nodes over OTLP, and a maintenance page (on, or automatic when the targets fail)
  • Access control through Traefik forwardAuth: allow/deny rules (IP, CIDR, path, method, country and AS number from GeoIP files you provide), organization SSO, shared password and PIN, one-time codes by email for a whitelist, share links, login throttling
  • An integration API with organization API keys (read or write, optional expiry) for nodes, resources, targets, headers, access settings, rules, share links, members, the audit log and blueprints, described by an OpenAPI document
  • An audit log of administrative changes and sign-ins, with retention
  • Declarative YAML blueprints: preview, all-or-nothing apply, export, web and API
  • OpenID Connect sign-in: server-wide identity providers with PKCE, domain allow-lists, optional organization for new accounts, and linking in settings
  • Email verification: confirmation links by email before login, when mail is configured
  • Two-factor authentication: authenticator app, security keys (WebAuthn) and recovery codes, asked on every way of logging in
  • Domain ownership proved by a DNS record (optionally required for resource names), and wildcard certificates through a DNS-challenge resolver
  • Alerts by signed webhook and email when a node stops polling or a target fails its health check, with a guard against webhooks aimed at private networks

Out of scope

  • Tunnel and VPN connectors, WireGuard sites, hole punching, private resources
  • Browser SSH/RDP/VNC gateway, AI gateway
  • Billing and license enforcement