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:

Provisioning

From the System page (or provision_traefik via MCP/API, admin-only, confirmation-gated), kimbap:

  1. Creates the kimbap-public Docker network, if it doesn't exist.
  2. Renders traefik.yml (static config: entrypoints, providers, ACME resolver) from KIMBAP_ACME_EMAIL/KIMBAP_ACME_STAGING.
  3. Renders dynamic/kimbap-ui.yml (the file-provider route for kimbap's own UI) from KIMBAP_ADMIN_DOMAIN and the port kimbap listens on.
  4. Creates an empty acme.json (mode 0600) if one doesn't exist yet.
  5. Writes Traefik's own docker-compose.yml and runs docker 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

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.