Duckeighty is a local HTTPS ingress layer for running multiple Docker Compose applications, and multiple worktrees of the same application, on a single machine without forcing the applications themselves to become multi-environment aware.
Duckeighty gives each exposed service a stable local URL in this form:
https://{project_name}-{service_name}.duckeighty.test
Examples:
https://blog-main-web.duckeighty.testhttps://blog-feature-foo-web.duckeighty.testhttps://blog-feature-foo-admin.duckeighty.test
The intended outcome is simple:
- each application service only needs to listen on
8080inside its container - the host does not need a growing list of published ports
- multiple worktrees can run at the same time
- existing Compose applications can opt in with a small local override file
- local HTTPS works without public tunnels
This document is written so that both humans and coding agents can implement and validate the system.
Duckeighty provides:
- a shared local ingress stack built with Traefik
- local wildcard DNS for
*.duckeighty.test - local TLS using a local CA
- a reusable Docker network shared by many Compose projects
- a small per-application
docker-compose.local.yamloverride pattern
Duckeighty does not require applications to know about branches, worktrees, or multi-tenant routing. Applications only need to expose an HTTP service inside the container.
At a high level, duckeighty works like this:
Browser
-> https://<project>-<service>.duckeighty.test
-> local DNS resolves *.duckeighty.test to 127.0.0.1
-> Traefik on the host-facing ingress stack
-> shared Docker network (shared_ingress)
-> target service on port 8080 inside a Compose project
The architecture has three parts:
-
Ingress stack
- runs Traefik
- runs local DNS
- owns the shared Docker network used for ingress
- terminates TLS using a locally trusted certificate
-
Application stack
- remains a normal Docker Compose app
- joins the shared ingress network through a local override file
- adds Traefik labels describing the hostname and backend port
-
Host setup
- trusts a local CA using
mkcert - routes
duckeighty.testDNS queries to the local DNS service
- trusts a local CA using
Duckeighty routes services by project name and service name.
Public hostname format:
{project_name}-{service_name}.duckeighty.test
The Compose project name is the environment boundary. In practice this comes from COMPOSE_PROJECT_NAME or docker compose -p ....
Each worktree should use a distinct project name.
Examples:
mainfeature-foofeature-login-redesign
The service name is the key under services: in the Compose file.
Examples:
webadminapi
Project names should be normalized before being used in hostnames.
Normalization rules:
- lowercase everything
- replace
/with- - replace
.with- - optionally replace other non-alphanumeric characters with
- - collisions are allowed
Examples:
feature/foo->feature-foofeature.foo->feature-fooFeature/Foo.Bar->feature-foo-bar
A practical implementation should pass a normalized value separately, for example as DUCKEIGHTY_PROJECT_NAME.
.
├── README.md
├── duckeighty.sh # single CLI entry point (setup-ca / setup-resolver / up / check)
├── infra/
│ ├── compose.yaml # traefik + dnsmasq, compose project name `duckeighty`
│ ├── traefik/
│ │ ├── traefik.yaml # static config, HTTP→HTTPS redirect
│ │ └── dynamic/tls.yaml # wildcard cert via file provider
│ ├── dns/dnsmasq.conf # address=/duckeighty.test/127.0.0.1
│ └── certs/ # mkcert output, gitignored
├── templates/
│ └── docker-compose.local.yaml # per-app override template
├── skills/ # agent skills (open SKILL.md spec)
│ ├── duckeighty-init/SKILL.md
│ ├── duckeighty-doctor/SKILL.md
│ └── duckeighty-integrate/
│ ├── SKILL.md
│ └── references/
├── examples/
│ └── simple-app/ # nginx:alpine + envsubst, no Dockerfile
│ ├── compose.yaml
│ ├── docker-compose.local.yaml
│ └── default.conf.template
└── .claude-plugin/
├── plugin.json # Claude Code plugin manifest
└── marketplace.json # self-listing single-plugin marketplace
The implementation includes:
- an ingress stack (
infra/) - an application override template (
templates/) - a single CLI entry point (
duckeighty.sh) - one runnable example (
examples/simple-app/) - three agent skills (
skills/duckeighty-{init,doctor,integrate}/)
The ingress stack is shared across many application stacks.
The ingress stack must:
- expose HTTP on
:80 - expose HTTPS on
:443 - resolve
*.duckeighty.testlocally - load a certificate for
duckeighty.testand*.duckeighty.test - connect to a shared Docker network named
shared_ingress
At minimum, the ingress stack should contain:
traefik- one DNS service such as
dnsmasqorCoreDNS
The shared network must be named:
shared_ingress
This network is expected to be a user-defined Docker network that can be reused as an external network by many Compose projects.
Each application remains a normal Compose application. Duckeighty integration is added with a local override file.
The initial implementation assumes:
- the application service listens on
8080 - the application does not need host-level port publishing
- the application can join an extra Docker network
The local override file should:
- attach the target service to
shared_ingress - add Traefik labels
- point Traefik to backend port
8080
services:
web:
expose:
- "8080"
networks:
default: {}
shared_ingress: {}
labels:
- "traefik.enable=true"
- "traefik.docker.network=shared_ingress"
- "traefik.http.routers.${DUCKEIGHTY_PROJECT_NAME}-web.rule=Host(`${DUCKEIGHTY_PROJECT_NAME}-web.duckeighty.test`)"
- "traefik.http.routers.${DUCKEIGHTY_PROJECT_NAME}-web.entrypoints=websecure"
- "traefik.http.routers.${DUCKEIGHTY_PROJECT_NAME}-web.tls=true"
- "traefik.http.services.${DUCKEIGHTY_PROJECT_NAME}-web.loadbalancer.server.port=8080"
networks:
shared_ingress:
external: true
name: shared_ingressLabels must be written in list form (- "key=value"), not a YAML
mapping. Compose does not expand ${VAR} inside mapping keys, so the
${DUCKEIGHTY_PROJECT_NAME} token in the router / service name would
stay literal and Traefik would end up with no matching router.
Duckeighty needs two host-side pieces:
- local CA trust
- local DNS routing for
duckeighty.test
The recommended approach is mkcert.
The setup flow should:
-
ensure
mkcertis installed -
run
mkcert -install -
generate a certificate covering:
duckeighty.test*.duckeighty.test
-
place the generated key and certificate where Traefik can read them
Expected output paths may look like this:
infra/certs/duckeighty.test.pem
infra/certs/duckeighty.test-key.pem
On macOS, the expected model is to use /etc/resolver/duckeighty.test so that only duckeighty.test queries are sent to the local DNS service.
Example resolver file:
nameserver 127.0.0.1
port 5354
The actual port may vary depending on the DNS implementation.
duckeighty uses 5354 to stay clear of macOS mDNSResponder on 5353.
./duckeighty.sh setup-resolver creates or updates this file (sudo).
A single shell script at the repository root provides all operator actions as subcommands:
./duckeighty.sh <subcommand> [args]
| Subcommand | Responsibility |
|---|---|
setup-ca |
Verify mkcert, run mkcert -install, generate a wildcard cert into infra/certs/. One-time. |
setup-resolver |
Write /etc/resolver/duckeighty.test pointing at 127.0.0.1:5354 (macOS, sudo). One-time. |
up |
Ensure the external shared_ingress network exists, then docker compose -f infra/compose.yaml up -d. |
check <host> |
Verify DNS (dig +short @127.0.0.1 -p 5354) and HTTPS reachability for a duckeighty hostname. |
Host name normalization (lowercase, non-alphanumeric runs collapse to
-, trim) is not a subcommand — COMPOSE_PROJECT_NAME does not accept
/ anyway, so the normalized value is what the user passes to both
COMPOSE_PROJECT_NAME and DUCKEIGHTY_PROJECT_NAME directly.
The implementation must configure Traefik so that:
- Docker labels are used for dynamic routing
- only explicitly labeled services are exposed
- HTTPS entrypoint is used for public routes
- the local wildcard certificate is loaded through the file provider
- backend traffic is forwarded to port
8080
Recommended Traefik behavior:
exposedByDefault=false- a
webentrypoint on:80 - a
websecureentrypoint on:443 - Docker provider enabled
- file provider enabled
The DNS service must answer local queries for:
*.duckeighty.test- optionally
duckeighty.test
and return:
127.0.0.1
The implementation may use either:
dnsmasqCoreDNS
The initial version only needs one of them.
./duckeighty.sh setup-ca./duckeighty.sh setup-resolver./duckeighty.sh upCOMPOSE_PROJECT_NAME does not accept /, so pass the normalized
hostname-safe value to both env vars:
NAME=feature-foo
COMPOSE_PROJECT_NAME="$NAME" DUCKEIGHTY_PROJECT_NAME="$NAME" \
docker compose -f compose.yaml -f docker-compose.local.yaml up -dhttps://feature-foo-web.duckeighty.test
NAME=feature-bar
COMPOSE_PROJECT_NAME="$NAME" DUCKEIGHTY_PROJECT_NAME="$NAME" \
docker compose -f compose.yaml -f docker-compose.local.yaml up -dNow both coexist:
https://feature-foo-web.duckeighty.testhttps://feature-bar-web.duckeighty.test
A correct implementation should make all of the following true:
- The ingress stack starts successfully.
shared_ingressexists and is reusable.- The wildcard certificate is trusted by the browser.
*.duckeighty.testresolves locally to127.0.0.1.- An example app listening on
8080becomes reachable through Traefik. - The same app can be started twice with two project names.
- The two resulting hostnames route to two distinct environments.
- The application itself does not need branch-aware or worktree-aware logic.
The bundled example runs the official nginx:alpine image unmodified
and uses its built-in /etc/nginx/templates/*.template envsubst step
to render a response body containing the project and service name.
No Dockerfile, no docker-entrypoint.d/ script, no image build.
The app:
- listens on
8080 - returns HTML with
project=<name>/service=<name>so parallel environments are visually distinguishable - is easy to run twice under different project names (see §Example usage)
A coding agent verifying duckeighty should check:
./duckeighty.sh upstarts theduckeightycompose project with traefik + dnsmasq running../duckeighty.sh check <host>.duckeighty.testreturns127.0.0.1from dig and a successful HTTPS status line (404 from Traefik when no app is yet registered for that host is the happy path).- Bring up the example app twice under two
NAMEvalues and confirm each hostname returns its ownproject=<name>body.
- no browser certificate warning on a
*.duckeighty.testURL curl -sI https://<host>.duckeighty.test/returns aHTTP/2status line and a Traefik-served response- stopping an app does not stop ingress
- stopping ingress does not destroy app stacks
The following projects and ideas are directly relevant to duckeighty.
sptth is a strong conceptual reference for this project.
Repository:
https://github.com/Jxck/sptth
Why it matters:
- it combines local DNS, local CA, and local reverse proxying
- it demonstrates the value of using real-looking local hostnames instead of
localhost - it validates the general shape of the problem duckeighty is solving
Duckeighty differs in one key way: it is designed specifically around Docker Compose applications, shared Docker networking, and multiple concurrent worktrees.
Repository:
https://github.com/FiloSottile/mkcert
Why it matters:
- it provides a practical local CA workflow
- it avoids public certificate issuance for local-only environments
- it is a good fit for browser-trusted local HTTPS
Documentation:
https://doc.traefik.io/traefik/providers/docker/
Why it matters:
- duckeighty relies on label-driven routing
- services can be exposed without publishing host ports individually
Documentation:
https://docs.docker.com/compose/how-tos/project-name/https://docs.docker.com/compose/how-tos/networking/
Why it matters:
- worktree separation depends on distinct Compose project names
- shared ingress depends on a reusable external Docker network
When implementing duckeighty, prefer these choices:
- keep the ingress stack separate from application stacks
- treat
shared_ingressas a reusable external network - generate a normalized
DUCKEIGHTY_PROJECT_NAMEoutside Compose - keep applications simple and assume backend port
8080 - implement one working end-to-end example before generalizing further
The most important property of duckeighty is this:
Applications should remain ordinary HTTP services. Duckeighty absorbs HTTPS, wildcard DNS, project separation, and host-based routing outside the applications.
Duckeighty ships three skills that follow the open SKILL.md spec, so they work across Claude Code, Cursor, Codex, Gemini CLI, Antigravity, and any other agent that supports the format:
| Skill | Purpose |
|---|---|
duckeighty-init |
One-time per-host bootstrap: clone duckeighty, install mkcert CA, install the macOS resolver, start ingress. |
duckeighty-doctor |
Read-only diagnosis: which of network / traefik / dnsmasq / resolver / DNS / CA / HTTPS is actually working. |
duckeighty-integrate |
Per-app: generate docker-compose.local.yaml so the app routes through *.duckeighty.test. |
The names are prefixed to avoid collisions with generic init / doctor
skills shipped by other tools or by the host agent itself.
# Claude Code, user scope (also: --agent cursor / --agent codex / --agent gemini)
gh skill install mackee/duckeighty duckeighty-init --agent claude-code --scope user
gh skill install mackee/duckeighty duckeighty-doctor --agent claude-code --scope user
gh skill install mackee/duckeighty duckeighty-integrate --agent claude-code --scope project
# Agent-agnostic fallback (writes to .agents/skills/<name>/)
gh skill install mackee/duckeighty duckeighty-init --scope user
gh skill install mackee/duckeighty duckeighty-doctor --scope user
gh skill install mackee/duckeighty duckeighty-integrate --scope projectduckeighty-init and duckeighty-doctor are host-wide operations, so
installing them at user scope is the natural fit. duckeighty-integrate
is per-app, so project scope keeps it with the repo it's used in.
/plugin marketplace add mackee/duckeighty
/plugin install duckeighty@duckeighty
Claude Code additionally namespaces plugin-installed skills under the
plugin name: /duckeighty:duckeighty-init, /duckeighty:duckeighty-doctor,
/duckeighty:duckeighty-integrate.
duckeighty-init— once per developer machine.duckeighty-integrate— once per app repo, createsdocker-compose.local.yaml. Added to.git/info/excludeby default so it stays out ofgit status.duckeighty-doctor— any time a URL 404s, NXDOMAINs, or TLS fails, to find the single next command to run.
The canonical templates that duckeighty-integrate emits live under
skills/duckeighty-integrate/references/ so they travel with the skill
regardless of distribution path.