Self-Hosting Deployment Notes: Traefik + Docker Compose + Domain-Only Access + Shared Middleware

This post summarizes what worked (and what hurt) when I deployed multiple self-hosted services in this repo (e.g. dockhand, vaultwarden, Checkmate, Miniflux). It focuses on repeatable defaults, pitfalls, and a practical debugging workflow.

Goals and non-negotiables

  • Domain-only access: Do not expose host ports for business services. Avoid IP:port bypassing auth or leaking admin panels.
  • Single entrypoint: Only open 80/443 externally. Let Traefik handle reverse proxying, TLS, and security headers.
  • Shared middleware: Run shared MongoDB / Redis / PostgreSQL once and reuse them across apps to reduce waste.
  • Never break userspace: Any change must not break existing online services (routing/port/network/auth conflicts).

Repo layout and my deployment defaults

  • One app per directory: e.g. dockhand/, vaultwarden/, Checkmate/, v2/ (Miniflux).
  • Each app has a runnable docker-compose.yml: avoid scattered upstream examples under contrib/.
  • Shared middleware is a separate project: middlewares/ runs postgres / mongodb / redis.
    • Shared middleware joins internal networks only and exposes no public ports.
    • Apps join shared networks via external networks.
  • One Traefik network: all services that Traefik should reach join an external network like traefik_proxy.
  • Prefer Docker labels (docker provider): use file provider only for a few non-container targets or special cases.

Standard deployment playbook (the core tricks)

1) Do not publish host ports

For public-facing services:

  • Use expose: (container-network visible only) instead of ports: (host visible).
  • Route traffic via Traefik using labels.

This immediately reduces:

  • Port conflicts (e.g. host 3000 already in use).
  • IP:port bypass risks.

2) Traefik labels (domain routing + TLS + security headers)

Typical label set (example):

  • traefik.enable=true
  • traefik.http.routers.<name>.rule=Host(`example.com`)
  • traefik.http.routers.<name>.entrypoints=websecure
  • traefik.http.routers.<name>.tls.certresolver=letsencrypt
  • traefik.http.routers.<name>.middlewares=security-headers@file
  • traefik.http.services.<name>.loadbalancer.server.port=<container_port>

3) Multi-network containers must pin the Traefik network

When a container joins both:

  • traefik_proxy (for Traefik)
  • infra_backend (for databases/caches)

Traefik may pick the wrong container IP (from a network Traefik cannot reach), resulting in 504 Gateway Timeout.

Fix: add this label to the service:

  • traefik.docker.network=traefik_proxy

This was the typical cause for Checkmate: adding it removed the 504 immediately.

Shared middleware (middlewares/)

Design principles

  • High cohesion, low coupling: middleware provides network services only.
  • Least privilege: no public ports by default; internal networks only.
  • Centralized persistence: volumes managed in middlewares/ for easier backup/migration.

Practical notes

  • Use a stable shared network name (e.g. infra_backend), and let apps join with external: true.
  • Use service names in connection strings (e.g. postgres / mongodb).
  • Prefer separate DB/schema/user per app to avoid data pollution.

App-specific pitfalls I hit this time

Dockhand (high-risk admin surface)

  • Do not publish host ports, domain + Traefik only.
  • Always add auth: protect it with Traefik basicAuth.
  • Keep it simple; complexity is how incidents are born.

Vaultwarden (Bitwarden-compatible)

This is where it is easy to “be clever” and break clients:

  • Do not put BasicAuth on the entire site: official clients and browser extensions will fail.
  • Correct approach: protect /admin only.
    • Client paths like /, /api, /identity must not be blocked by BasicAuth.
  • If “the UI loads but login fails”, check:
    • whether Cloudflare is challenging API requests (see below)
    • whether /api is treated as ServerConfig (may require rewriting to /api/config in Traefik)

Checkmate (multi-network + MongoDB)

  • Sharing MongoDB + multiple networks often triggers the Traefik wrong-network issue:
    • symptoms: 504
    • fix: traefik.docker.network=traefik_proxy

Miniflux (rss reader)

  • Miniflux requires PostgreSQL. No MongoDB/Redis is needed (unless you add RSSHub/Browserless, etc.).
  • HEAD / returning 405 can be normal as long as GET works; do not misdiagnose it as downtime.

Cloudflare pitfall: clients/extensions cannot log in

I hit the same issue twice:

  • Vaultwarden browser extension: Cloudflare challenge → 403
  • Reeder (Google Reader API): Cloudflare challenge → 403

Common signals:

  • response headers contain server: cloudflare + cf-mitigated: challenge
  • requests never reach Traefik/app logs

Mitigation options (recommended order):

  • A: set the subdomain to DNS-only (grey cloud): the most robust, no client-side hacks needed.
  • B: keep proxy (orange cloud), but skip challenge for this host/path:
    • at minimum allow /api, /identity, /reader/api/0, /accounts/ClientLogin, etc.

Key takeaway:

  • “It works in a browser” does not mean “it works in a client”. Browsers can pass challenges; clients usually cannot.

Debug playbook (fast to slow)

1) Decide whether the request reaches your server

  • Cloudflare 403 challenge: request never reaches your server. Fix Cloudflare/WAF first.
  • Traefik 404/504: request reaches Traefik, but routing/network/backend is wrong.
  • Backend 4xx/5xx: request reaches the app. Inspect app logs/config.

2) Traefik-side debugging

  • Check access logs: which router/service handled the request and what status code it returned.
  • Multi-network containers: suspect missing traefik.docker.network first.
  • Same host defined twice: suspect file-provider vs docker-provider conflicts (delete legacy file routes).

3) App-side debugging

  • docker logs <container>: verify startup, listening port, DB connectivity.
  • Verify you did not accidentally use ports: and expose/conflict host ports.

Final security checklist (before going public)

  • No business ports published on the host (only keep 80/443)
  • Admin surfaces must be authenticated (dockhand, vaultwarden /admin, etc.)
  • Databases/caches are never exposed to the public internet
  • No Traefik routing conflicts (avoid defining the same host in both file-provider and docker-provider)
  • Cloudflare must not challenge API paths (clients/extensions/third-party integrations will break)

Comments