- Elixir 97.6%
- Shell 1.2%
- HTML 0.4%
- JavaScript 0.4%
- CSS 0.2%
- Other 0.1%
| .github/workflows | ||
| assets | ||
| config | ||
| dev/traefik | ||
| lib | ||
| priv | ||
| rel | ||
| scripts | ||
| test | ||
| .dockerignore | ||
| .formatter.exs | ||
| .gitignore | ||
| .tool-versions | ||
| AGENTS.md | ||
| compose.yaml | ||
| Containerfile | ||
| Containerfile.dev | ||
| mix.exs | ||
| mix.lock | ||
| README.md | ||
| renovate.json | ||
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
(
adminmanages,memberreads). Roles never carry over between organizations. - Every tenant-owned row references its organization, and every context
function takes a
Scopethat 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.exswalks 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=offto 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:
- A known identity signs in its user.
- 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.
- 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.
- 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_SIGNUPsays. 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.
- 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
- 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/xbut not/apix, while/apimatches 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
Hoston 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-Lengthand so on) cannot be set.Remote-UserandRemote-Emailcannot 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
downif any node that reported lately says so,upif all do, andno reportwhen none did in the last five minutes (no health check, metrics not set up, or the node went quiet). - Only the
traefik_service_server_upmetric 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,503or504(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
POSTof JSON (id,type,occurred_at,organization,data) to a URL. Every request carriesx-traefik-control-timestampandx-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 fromSECRET_KEY_BASE(signing needs the secret itself, so it cannot be a hash); changingSECRET_KEY_BASEmakes 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
Hostheader 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=truelifts 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 asDE),country_not(any country but that one) andasn(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.mmdbThe files are read at startup and again whenever they change on disk (checked every minute,
TRAEFIK_CONTROL_GEOIP_RELOAD_SECONDS), so refreshing them withgeoipupdateor 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 nocountryorasnrule; a deny rule forcountry_notmatches it (so "deny everyone outside Germany" also refuses what nobody can place, and every client if the database is gone), and an allow rule forcountry_notnever does (sign-in is never skipped on a guess). Blueprints usematch: country,country_notorasn, 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 (seeTRAEFIK_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 asaccess.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 a401with the login URL instead of a redirect. -
SSO sessions pass
Remote-User(user id) andRemote-Emailto the backend; sessions made with an emailed code passRemote-Emailonly. 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_URLwhen 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=trueonly when the control plane itself is behind a reverse proxy you control; login throttling then uses the lastX-Forwarded-Forentry 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
5432uses the entrypointtcp-5432(udp-5353for 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 use53. 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: trueand 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, nota.b.example.com) ask Traefik for one certificate (example.complus*.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 environmentTraefik 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, includingPOST .../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
GETonly. 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,0keeps 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.appliedsummary. - 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
passwordare 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:
planandapplyneed a write key;GET /api/v1/blueprintworks with a read key. Problems are a422whoseerrorsmap 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
openmode before its owner is invited, so preferinvite_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
devwith a node whose token is indev/traefik/traefik.yml - resource
whoami.localhostrouted to thewhoamicontainer - TCP resource on port 9000 and UDP resource on port 9001, routed to the
whoami-tcpandwhoami-udpcontainers (their entrypoints are declared indev/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 teston each backend, then a migration round trip (mix ecto.rollback --allandmix ecto.migrateon 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.ymlrunsscripts/propose-release.shevery Monday (and on demand from the Actions tab). If commits since the latest release tag changed files that ship in the images, it opens achore(release): bump version to <x.y.z>pull request that raises the version inmix.exs, lists those commits, and is set to merge once the required checks pass. Files that ship are whatContainerfilecopies into the build (lib,priv,assets,config,rel,mix.exs,mix.lock) and theContainerfileitself, 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 aBREAKING CHANGE:footer) raises the major version, afeatthe minor, and anything else (dependency updates, fixes, performance) the patch:1.0.0becomes1.0.1,1.1.0or2.0.0. - By hand: raise
versioninmix.exsin achore(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:
.github/workflows/tag.ymlrunsscripts/tag-release.shfor the push tomain: ifversionhas 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 withRENOVATE_TOKEN, a real account's token, because a tag pushed with the job's own token may not start the next workflow..github/workflows/release.ymlrunsscripts/release.shon the tag, which refuses a tag that does not equal themix.exsversion or is not onmain, builds both images, pushes them (a version already in the registry is never replaced, so a re-run after a failure is safe), moveslatestonto 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 /healthzanswers200while the process serves requests (liveness, startup);GET /readyzanswers200when the database answers a query within two seconds and503otherwise (readiness). They are plain HTTP to the pod address, exempt from thehttpsredirect, and are not logged. Do not use/readyzfor 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_COOKIEandPOD_IPset the pods start Erlang distribution and find each other throughDNS_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) andDIST_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 rpcandremotetherefore needRELEASE_COOKIEandPOD_IPas well. - Read-only root filesystem. The image runs as uid 65534 and writes only to
/tmp(RELEASE_TMP) and, for SQLite,/data; mount anemptyDiron/tmp. Tested with--read-only --tmpfs /tmp --cap-drop ALL. - Shutdown. The process stops on
SIGTERM; give the pod a shortpreStopsleep 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
mainpass (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/elixirtoolchain image and.tool-versionsmove together with the CI cache keys and Dialyzer PLTs, so upgrading Elixir/OTP is a deliberate change; thelatestthird-party images incompose.yamland 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_TOKENrepository secret, the token of a dedicated account with write access to this repository only (repository and issue scopes), not a personal token;tag.ymluses it to push release tags. An optionalRENOVATE_GITHUB_COM_TOKENsecret (a github.com token with no scopes) avoids rate limits when looking up GitHub-hosted dependencies (Renovate reportsgithub-token-requiredfor them without it).
Connecting your own Traefik
-
Log in, create an organization (system administrators) and a node under Nodes. Copy the token; it is shown once.
-
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>" -
Create a resource, add targets, and tick the node. The route appears in Traefik on its next poll (within 5 seconds).
-
Optional, to see target health: add the
metrics.otlpsnippet 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