Domains & TLS
kimbap provisions and manages a single Traefik instance that fronts both kimbap's own UI and every project it deploys, with automatic Let's Encrypt certificates.
Why this needs two different routing mechanisms
Traefik discovers what to route in two ways here, because kimbap and its projects aren't the same kind of thing:
- Project containers are Docker containers. Traefik's Docker
provider (
exposedByDefault: false) auto-discovers them by label — standard, well-established Traefik usage. - kimbap itself is not a container. It's deliberately a native binary/systemd process (see Overview for why — it needs to be able to install Docker on a host where Docker doesn't exist yet). Traefik's Docker provider has nothing to discover here, so kimbap's own UI is routed via Traefik's file provider instead — a static route pointing at kimbap's host process.
Provisioning
From the System page (or provision_traefik via MCP/API, admin-only,
confirmation-gated), kimbap:
- Creates the
kimbap-publicDocker network, if it doesn't exist. - Renders
traefik.yml(static config: entrypoints, providers, ACME resolver) fromKIMBAP_ACME_EMAIL/KIMBAP_ACME_STAGING. - Renders
dynamic/kimbap-ui.yml(the file-provider route for kimbap's own UI) fromKIMBAP_ADMIN_DOMAINand the port kimbap listens on. - Creates an empty
acme.json(mode0600) if one doesn't exist yet. - Writes Traefik's own
docker-compose.ymland runsdocker compose up -d.
Re-running provisioning (e.g. after changing the ACME email or admin domain) is idempotent — safe to do any time, including as part of every kimbap boot to reconcile drift.
How kimbap's own UI gets routed
Traefik's container gets extra_hosts: host.docker.internal:host-gateway,
and the file-provider route points at
http://host.docker.internal:<kimbap's port>:
http:
routers:
kimbap-admin:
rule: "Host(`admin.example.com`)"
entryPoints: ["websecure"]
service: kimbap-admin
tls:
certResolver: letsencrypt
services:
kimbap-admin:
loadBalancer:
servers:
- url: "http://host.docker.internal:8080"
Because providers.file.watch: true, editing the admin domain and
re-provisioning hot-reloads this route with no Traefik restart — useful
if you ever want to change kimbap's own domain without dropping in-flight
project traffic.
How project domains get routed
Adding a domain to a project (UI, POST /api/projects/{slug}/domains, or the
add_domain MCP tool) does not edit your docker-compose.yml. Instead,
kimbap maintains a separate, auto-generated
docker-compose.kimbap-labels.yml override per project, regenerated on every
domain change:
services:
web:
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp-web-app-example-com.rule=Host(`app.example.com`)"
- "traefik.http.routers.myapp-web-app-example-com.entrypoints=websecure"
- "traefik.http.routers.myapp-web-app-example-com.tls.certresolver=letsencrypt"
- "traefik.http.services.myapp-web-app-example-com.loadbalancer.server.port=80"
networks:
- kimbap-public
networks:
kimbap-public:
external: true
kimbap always deploys with both files layered
(-f docker-compose.yml -f docker-compose.kimbap-labels.yml), and attaches
the routed service to the shared kimbap-public network — Traefik's Docker
provider needs real L3 connectivity to a container, not just its labels, to
route to it.
If a project is currently deployed when you add/remove a domain, kimbap
re-runs docker compose up -d immediately so the label change takes effect
— Traefik's Docker provider then picks it up live via Docker events, no
Traefik restart needed.
Why a separate file instead of editing your compose.yml directly? Label injection is churny (every domain add/remove touches it), and mixing kimbap-owned and user-owned content in one file risks clobbering hand-edits or generating diff noise if you track the file in git yourself. A pure compose-native multi-file merge avoids all of that, and makes "detach kimbap from this project" as simple as deleting one generated file.
Network isolation as a side effect
Only services with at least one domain get attached to kimbap-public.
Everything else in a project (a database, an internal cache, ...) stays only
on the project's own default compose network — so giving a service a public
domain is also the thing that makes it reachable from outside the project at
all.
Let's Encrypt / ACME
KIMBAP_ACME_EMAIL— required for real certificate issuance.KIMBAP_ACME_STAGING=true— use Let's Encrypt's staging directory while testing, to avoid hitting production rate limits.- The HTTP-01 challenge runs on the
webentrypoint (port 80), which also handles the plain-HTTP → HTTPS redirect.
Static-config changes (ACME email/staging, entrypoints) require a
Traefik restart to take effect — re-provisioning's docker compose up -d
handles that automatically. Domain/label changes are hot-reloaded via
the file/Docker providers, as described above — no restart.
What you can only verify on a real host
Real Let's Encrypt certificate issuance requires a publicly resolvable
domain with an A record pointing at your server, reachable on port 80 for
the HTTP-01 challenge. Local testing (e.g. with *.localtest.me, which
resolves to 127.0.0.1) validates the entire routing/label mechanism
end-to-end, but certificate issuance itself will fail unless your host is
actually reachable from the public internet on that domain — which is
expected, not a bug.