add-traefik-deploy
Wire an application into the shared Traefik reverse proxy (dev and, per §4,
production) and stand up the GitLab CI pipeline that builds, publishes to the
GitLab Container Registry, and deploys by pulling from that registry.
Reference layout (already running — do not recreate)
This skill assumes a shared Traefik stack already runs on the dev host and
owns ports 80/8080. The canonical source for it is the traefik repo
(canonical clone path ~/git/traefik):
traefik/
compose.yaml # the whole stack: service `traefik`, ports 80/443,
# docker.sock mount, and ALL Traefik config as `command:`
# flags — there is no traefik.yml / static config file
compose.prod.yaml # prod-only overlay: TLS (real ACME), HTTP->HTTPS
# redirect, exposedByDefault=false — layered via
# COMPOSE_FILE=compose.yaml:compose.prod.yaml
.gitlab-ci.yml # validate on `ci`; deploy_traefik on `dev-env`
# (automatic); deploy_traefik_prod on `proxmoxdocker3`
# (manual, needs deploy_traefik first)
README.md
The same host pattern runs both environments: dev's stack is compose.yaml
alone with DOMAIN=dev.burnttech.com; prod layers compose.prod.yaml on top
with DOMAIN=burnttech.com — no separate "prod" subdomain, production
apps live directly on the bare domain. ~/git/authelia is the first real app
deployed this way end-to-end (dev at auth.dev.burnttech.com, prod at
auth.burnttech.com) and is the canonical reference for §4.
Traefik is configured entirely through CLI flags in compose.yaml's
command: list. The ones this skill depends on:
command:
- "--providers.docker.exposedByDefault=true"
- "--providers.docker.network=traefik"
- "--providers.docker.defaultRule=Host(`{{ normalize .Name }}.${DOMAIN:-dev.burnttech.com}`)"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
Read ~/git/traefik/compose.yaml before changing anything here — if you are
looking for a traefik.yml, there isn't one, and inventing one would shadow
nothing and take effect never.
That stack publishes the shared Docker network traefik on the host. Every
app this skill touches just joins that external network — it does not run its
own Traefik, publish 80/443, or mount the Docker socket. Default hostname for a
joined container is <container-name>.dev.burnttech.com — from the provider's
defaultRule above, where ${DOMAIN} defaults to dev.burnttech.com unless
the stack's environment overrides it. Override per-service with a Traefik router
Host(...) label.
Because exposedByDefault=true is set, a container on the traefik network is
routed without any traefik.enable=true label. This skill still sets that
label explicitly: it is harmless, it documents intent, and it keeps the compose
file correct if the provider is ever flipped to exposedByDefault=false.
For an app that also needs a production deploy path and is going behind
the shared Traefik proxy, §4 below is the fully prescriptive prod delta —
don't reach for setup-docker-pipeline for that case, it would duplicate and
drift from this. Use setup-docker-pipeline only for apps headed to
production that are not going behind shared Traefik (not user-facing over
HTTP, or fronted some other way). This skill remains authoritative for
everything Traefik, dev and prod alike.
Canonical example
~/git/mad-jars-web is the cleanest end-to-end reference — a single-service Rust
app with a Dockerfile, a docker-compose.yml that joins traefik, and a
.gitlab-ci.yml doing exactly test → build/push → pull+deploy. Mirror its
shape; swap the test-stage image and cargo test/pnpm test for the app's
language. ~/git/friendshub (Node, multi-service, env-file secrets) and
~/git/stocker (multi-service monorepo with build matrix) are heavier
variations on the same pattern — consult them only when the app genuinely needs
more than one container.
What the app repo needs
Three files, all adapted from mad-jars-web:
Dockerfile — multi-stage build → slim runtime, non-root user, a real
HEALTHCHECK-able port.
docker-compose.yml — service joins the external traefik network,
sets Traefik labels, and reads image: from $CI_REGISTRY_IMAGE /
$IMAGE_TAG so CI can pull the published image instead of rebuilding.
.gitlab-ci.yml — test on ci, build+publish on dev-env, pull+deploy on
dev-env; default-branch-only for build/deploy.
Read all three reference files before writing the app's versions — copy the
structure, change only what the language/app demands.
1. Make the app Traefik-aware
Healthcheck + non-root runtime (Dockerfile)
The image must expose one HTTP port and respond to a health probe on it, and the
container must not run as root. From mad-jars-web/Dockerfile (Rust; adapt the
build stage for other languages):
FROM rust:1.95-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --locked
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install --no-install-recommends --yes curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --uid 10001 appuser
COPY --from=builder /app/target/release/the-app /usr/local/bin/the-app
EXPOSE 3000
ENV BIND_ADDR=0.0.0.0:3000
USER appuser
# optional entrypoint that fixes named-volume ownership before dropping privs:
# COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint
# ENTRYPOINT ["docker-entrypoint"]
CMD ["the-app"]
For Node, see friendshub/docker/Dockerfile (production target, single image
serving app and worker via different command:). Whatever the language,
make sure: one EXPOSEd port, a /health (or equivalent) endpoint returning
200, and a non-root USER.
Join the traefik external network + labels (docker-compose.yml)
services:
the-app:
# Local `docker compose up --build` rebuilds; CI's deploy sets these vars
# so `pull` + `up -d` use the published registry image instead.
image: "${CI_REGISTRY_IMAGE:?Set CI_REGISTRY_IMAGE}:${IMAGE_TAG:?Set IMAGE_TAG}"
restart: unless-stopped
networks:
- traefik
labels:
- traefik.enable=true
# Optional — without it, Traefik's defaultRule already serves
# <container-name>.dev.burnttech.com. Add it only to override the host.
- traefik.http.routers.the-app.rule=Host(`the-app.dev.burnttech.com`)
- traefik.http.routers.the-app.entrypoints=web
- traefik.http.services.the-app.loadbalancer.server.port=3000
environment:
BIND_ADDR: 0.0.0.0:3000
healthcheck:
test: ["CMD-SHELL", "curl --fail --silent http://127.0.0.1:3000/health || exit 1"]
interval: 10s
timeout: 3s
retries: 6
start_period: 5s
# named volumes start root-owned; if the app writes to one, either chown in
# an entrypoint (see mad-jars-web/docker-entrypoint.sh) or run a one-shot
# init. Keep stateful volumes out of the traefik network.
volumes:
- the-app-data:/data
volumes:
the-app-data:
networks:
traefik:
external: true
Rules:
networks.traefik.external: true — the network already exists on the
host (the shared Traefik stack created it). Never declare it as a local
bridge here; a duplicate name will either fail or shadow the wrong network.
traefik.enable=true + entrypoints=web only. Do not wire a
websecure/443 router or TLS labels in dev. The shared stack does define a
websecure entrypoint on :443 and publishes the port, but it has no
certificate resolver configured — so a websecure router would match and
then fail to serve a usable certificate. TLS is a prod concern; the stub in
§4 covers adding the resolver.
loadbalancer.server.port is only mandatory when the container exposes
multiple ports or Traefik can't infer it. One-port apps can omit it.
- Apps that need a private sidecar (postgres, minio, …) declare a second
internal bridge network alongside traefik and join both; see
friendshub/docker/docker-compose.yml (internal: { driver: bridge } +
traefik: { external: true }). Never expose the database port to the host
in anything that goes to prod — 127.0.0.1:5432:5432 dev-convenience binds
get a # remove in production comment.
If secrets are needed, mirror friendshub's pattern: an ENV_FILE GitLab
File-type CI/CD variable written to a chmod 700 directory on the runner
host and loaded via docker compose --env-file. Do not commit .env files to
the repo.
2. GitLab CI — test, build/publish, deploy-to-dev
Runner tags on this GitLab instance are fixed:
ci = clean Docker-executor runner (no host Docker, good for lint/test);
dev-env = shell executor on the dev host with a working Docker install
and a checkout of the shared Traefik stack. Build + deploy must run on
dev-env; tests run on ci. Never tag a build/push job ci — that runner's
daemon isn't configured for the GitLab registry (confirmed in
friendshub/.gitlab-ci.yml's comments).
Full pipeline (single service)
Adapt from mad-jars-web/.gitlab-ci.yml. Three stages, default-branch gating
on build+deploy:
stages:
- test
- build
- deploy
variables:
IMAGE_TAG: "$CI_COMMIT_SHA"
DOCKER_BUILDKIT: "1"
# ---- test stage: language-specific, runs on `ci` -------------------------
# See "Test-stage snippets" below; pick the one matching the app.
test:
stage: test
# image: <language image>
tags:
- ci
script:
- <run tests>
# ---- build: docker build + push to GitLab registry, on dev-env -----------
build_image:
stage: build
tags:
- dev-env
needs:
- test
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
- docker build --pull --tag "$CI_REGISTRY_IMAGE:$IMAGE_TAG" --tag "$CI_REGISTRY_IMAGE:dev" .
- docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
- docker push "$CI_REGISTRY_IMAGE:dev"
- docker logout "$CI_REGISTRY"
# ---- deploy: pull published image, up -d, healthcheck, on dev-env --------
deploy_dev:
stage: deploy
tags:
- dev-env
needs:
- build_image
resource_group: dev-environment
environment:
name: development
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
- export IMAGE_TAG="$CI_COMMIT_SHA"
- docker compose pull
- docker compose up -d --remove-orphans --wait --wait-timeout 60
- docker compose exec -T the-app curl --fail --show-error --silent http://127.0.0.1:3000/health
- docker logout "$CI_REGISTRY"
Contract this skill enforces:
- Build publishes; deploy pulls. Never rebuild on deploy. The deploy job
docker logins, docker compose pulls (using the image published in the
build stage), then up -d. The :-?-required image: interpolation in
docker-compose.yml is what makes pull fetch the registry image rather
than build.
build_image pushes two tags — $IMAGE_TAG (= $CI_COMMIT_SHA,
immutable) and :dev (rolling latest on the default branch). Deploy pins to
the SHA tag for reproducibility (export IMAGE_TAG="$CI_COMMIT_SHA" in the
deploy script); :dev is a convenience for humans docker pulling by hand.
- Default-branch gating.
build_image and deploy_dev carry
rules: if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH. MR pipelines run only
the test stage — they validate, they must not push or touch the dev host.
resource_group: dev-environment (or <project>-dev) on the deploy job
serializes deploys so two default-branch pushes can't up -d the same stack
concurrently.
- Health check after
up. deploy_dev ends with docker compose exec ... curl /health (or the app's equivalent). A deploy that starts containers but
leaves them unhealthy fails the pipeline — don't relax this.
- Logout in
after_script is a nice-to-have; mad-jars-web does it inline
at the end of the script, friendshub uses after_script. Either, not
neither.
Test-stage snippets (pick one)
Rust (mad-jars-web) — plain Docker executor, no extra services:
test:
stage: test
image: rust:1.95-bookworm
tags:
- ci
script:
- cargo test --locked
Node (SvelteKit/Express + Postgres) (friendshub) — Glab services:
attached Postgres, runs on the clean Docker executor without Docker CLI/DinD:
test:
stage: test
image: node:22-alpine
services:
- name: postgres:17-alpine
alias: postgres
variables:
POSTGRES_USER: app_ci
POSTGRES_PASSWORD: app_ci
POSTGRES_DB: app_ci
DATABASE_URL: postgresql://app_ci:app_ci@postgres:5432/app_ci
script:
- |
for i in $(seq 1 20); do
nc -z postgres 5432 && break
echo " waiting for postgres ($i/20)..."; sleep 2
done
- corepack enable pnpm
- pnpm install --frozen-lockfile
- pnpm run db:migrate
- pnpm run check
- pnpm test
tags:
- ci
Generic "container with /health" — for apps whose tests are themselves
dockerized, or where there's no meaningful unit-test stage yet:
test:
stage: test
image: docker:29.6.2-cli
tags:
- ci
script:
- docker --version
- docker compose -f docker-compose.yml config --quiet
(The docker-cli image on a clean Docker executor can validate the compose file
without a running daemon — same trick traefik/.gitlab-ci.yml's
validate_compose uses. Real build/push still happens on dev-env.)
Multi-service variations
- Monorepo / build matrix: per-service build jobs sharing a hidden
.build-docker-template: &build-docker-template, each with variables: SERVICE_NAME: …, the deploy job needs:-ing all of them and pulling by
$CI_COMMIT_SHORT_SHA — see stocker/.gitlab-ci.yml. Keep the publish→pull
contract: build jobs push, deploy job pulls, nothing rebuilds.
- App + worker sharing one image: one build job, two compose services with
the same
image:/build: and different command: — see friendshub.
- Ad-hoc re-deploy of an older branch: a
when: manual deploy job keyed off
$CI_COMMIT_REF_SLUG-tagged images — optional, see stocker's
deploy:dev:adhoc. Not required for the basic dev path.
3. Verify on a branch before merging
The dev deploy only runs on the default branch, so verify on a feature branch
first:
- On the branch, the
test job runs (it's not default-branch-gated). Get it
green.
docker compose config --quiet locally to catch compose errors CI would.
The image: line uses ${VAR:?} required interpolation, so export both
first or config aborts on the unset variable — that abort is the feature
working, not a compose error:
CI_REGISTRY_IMAGE=the-app IMAGE_TAG=dev docker compose config --quiet
- Optionally build the image locally and
docker compose up against the dev
host's traefik network (if you're on that host) to confirm labels route —
curl -H 'Host: the-app.dev.burnttech.com' http://<dev-host>/health should
hit the container.
Only merge to the default branch once test is green. The merge triggers
build_image → deploy_dev; watch that downstream pipeline (use the
mr-pipeline-check / execute-ticket skills' polling pattern — never a
single-call while loop) and confirm deploy_dev's healthcheck line passes.
If deploy_dev fails at the up -d or healthcheck step, common causes, in
order of likelihood:
docker compose pull failed/the image wasn't pushed — check build_image
actually ran (default-branch rule) and the registry credentials resolve
(echo "$CI_REGISTRY_USER" non-empty on the runner).
- The
traefik external network doesn't exist on the host — start the shared
Traefik stack first (docker compose up -d in ~/git/traefik).
- Wrong
loadbalancer.server.port — the port the app listens on inside the
container must match. Container logs (docker compose logs the-app) tell
you fast.
- Health endpoint path differs from
/health — align the compose
healthcheck.test and the CI exec ... curl line with the app's real path.
4. Production
Dev is §1–3 above. Production is the delta on top of it — same app image,
same Traefik network concept, different host, domain, and TLS posture.
Canonical reference: ~/git/authelia (docker-compose.prod.yml +
.gitlab-ci.yml's deploy_prod), the first app wired this way end-to-end;
~/git/traefik (compose.prod.yaml + deploy_traefik_prod) for the proxy
side of the same delta.
4.1 Domain
Prod apps live on the bare domain, not a prod. subdomain:
<container-name>.burnttech.com, from the prod stack's
DOMAIN=burnttech.com (vs. dev's DOMAIN=dev.burnttech.com) feeding the same
defaultRule=Host({{ normalize .Name }}.${DOMAIN}). Confirmed by both
traefik's own dashboard (traefik.burnttech.com) and authelia
(auth.burnttech.com). Never write *.prod.burnttech.com — that domain
doesn't exist and isn't the convention.
4.2 docker-compose.prod.yml — TLS via the shared resolver
traefik/compose.prod.yaml puts --entrypoints.websecure.http.tls.certresolver=le
on the entrypoint itself, so every router on websecure gets a real
certificate automatically — apps add no certresolver/TLS labels of their
own. The app's prod override only needs to move its router onto that
entrypoint (labels merge by key, so only the changed ones need restating):
# docker-compose.prod.yml — deploy with:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
services:
the-app:
labels:
- traefik.http.routers.the-app.rule=Host(`the-app.burnttech.com`)
- traefik.http.routers.the-app.entrypoints=websecure
If the base docker-compose.yml has no explicit Host(...) rule (relying on
defaultRule), the prod override doesn't need the rule= line either — only
entrypoints=websecure changes. Dev's docker-compose.yml must still not
reference websecure on its own (§1) since only the prod overlay's Traefik
instance has a resolver configured for it.
4.3 Runner tag
Production deploy jobs run on proxmoxdocker3 — the real tag for the
shell executor on the prod host (confirmed in both traefik and authelia
.gitlab-ci.yml), not a placeholder prod-env. Never tag a prod deploy job
dev-env.
4.4 CI — stage, gating, image
Reuse the same SHA-tagged image the dev build stage already pushed — do
not add a separate prod build job or a :latest tag unless the app's own
image needs to differ between environments (the one real exception is
authelia's Dockerfile.prod, needed only because its prod runner is a
Docker executor talking to the host daemon over a socket, so a bind-mounted
./config would resolve against the host filesystem instead of the job's
checkout — that doesn't apply to apps like mad-jars-web that already build
a self-contained image).
deploy_prod:
stage: deploy
tags:
- proxmoxdocker3
needs:
- deploy_dev # prod follows dev, same pipeline
resource_group: prod-environment
environment:
name: production
url: https://the-app.burnttech.com
rules:
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" --username "$CI_REGISTRY_USER" --password-stdin
- export IMAGE_TAG="$CI_COMMIT_SHA"
- docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
- docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60
- docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T the-app curl --fail --show-error --silent https://127.0.0.1:3000/health
- docker logout "$CI_REGISTRY"
- Gating:
when: manual is the default — a human decides when an app
goes to prod (mirrors traefik/deploy_traefik_prod, which additionally sets
allow_failure: true so the pipeline stays green while the manual job
waits). authelia/deploy_prod is the one deliberate exception: it runs
automatically right after deploy_dev because there's no integration-test
stage yet to gate on — treat manual as the default and only drop it with an
explicit reason in a comment, the way authelia's CI does.
needs: [deploy_dev] — prod follows dev in the same pipeline so a bad
config is always caught on the dev host first (traefik's comment: "Prod
follows dev, so a bad ingress config is caught on the dev host first").
resource_group: prod-environment (or <project>-prod) — same
serialization reasoning as dev's resource_group, on the prod host instead.
4.5 Secrets
Same ENV_FILE/CI-variable pattern as dev, but scoped to the production
GitLab environment with its own protected + masked values — never reuse a
dev secret in prod. authelia's four AUTHELIA_* variables (LDAP password,
JWT secret, session secret, storage encryption key), each set with
environment scope development / production, is the pattern to copy:
same variable names, environment-scoped to different values.
4.6 No host-published ports
Strip every 127.0.0.1:<port>:<port> dev-convenience bind (DB, admin UIs,
etc.) from the prod compose override — those exist for local debugging on the
dev host only.
4.7 Optional: gate the app behind Authelia (2FA / SSO)
If the app needs authentication in front of it rather than handling its own
login, put it behind the already-running ~/git/authelia deployment instead
of building auth into the app. This skill does not stand Authelia up (that's
authelia's own repo); it just wires an app to consume it. Add one
middleware label to the app's prod router (Authelia's own compose
declares the authelia middleware on the shared traefik network, so any
router on that network can reference it):
services:
the-app:
labels:
- traefik.http.routers.the-app.middlewares=authelia@docker
That's it on the app side — Authelia's forward-auth intercepts the request,
handles LDAP login + 2FA (or SSO cookie), and forwards
Remote-User/Remote-Groups/Remote-Name/Remote-Email headers through
once authenticated. authelia/config/configuration.prod.yml's
access_control already defaults every *.burnttech.com host to
two_factor, so a newly deployed prod app is covered as soon as the label is
added — no Authelia-side config change needed unless the app needs a
different policy (e.g. one_factor, or a bypass for a public health
endpoint), in which case add a rule for that specific
domain: '<container-name>.burnttech.com' above the wildcard rule in
authelia's config/configuration.prod.yml (rules are evaluated in order,
first match wins). Dev has the equivalent *.dev.burnttech.com -> two_factor
rule in the base config/configuration.yml, so the same label works
identically in dev if the app wants auth gating there too.
5. Report back
After wiring the app, report:
- the app repo and the three files changed (
Dockerfile,
docker-compose.yml, .gitlab-ci.yml),
- the dev hostname the app will be reachable at once deployed
(
<container>.dev.burnttech.com or the explicit Host(...) override),
- the runner tags used per stage (
ci for test, dev-env for build+deploy)
and the default-branch gating,
- the registry tags the build pushes (
:<sha> + :dev),
- whether a production deploy path was added (§4): the prod hostname
(
<container>.burnttech.com), the proxmoxdocker3 runner tag, the gating
(manual by default), and whether it's wired behind Authelia,
- the state of the first real dev pipeline (green, or the failing job + the
diagnosis above), if the merge was made.