Skip to content

mackee/duckeighty

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

duckeighty

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.test
  • https://blog-feature-foo-web.duckeighty.test
  • https://blog-feature-foo-admin.duckeighty.test

The intended outcome is simple:

  • each application service only needs to listen on 8080 inside 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.


What duckeighty does

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.yaml override 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.


How it works

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:

  1. Ingress stack

    • runs Traefik
    • runs local DNS
    • owns the shared Docker network used for ingress
    • terminates TLS using a locally trusted certificate
  2. 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
  3. Host setup

    • trusts a local CA using mkcert
    • routes duckeighty.test DNS queries to the local DNS service

Naming model

Duckeighty routes services by project name and service name.

Public hostname format:

{project_name}-{service_name}.duckeighty.test

Project name

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:

  • main
  • feature-foo
  • feature-login-redesign

Service name

The service name is the key under services: in the Compose file.

Examples:

  • web
  • admin
  • api

Project name normalization

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-foo
  • feature.foo -> feature-foo
  • Feature/Foo.Bar -> feature-foo-bar

A practical implementation should pass a normalized value separately, for example as DUCKEIGHTY_PROJECT_NAME.


Repository layout

.
├── 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}/)

Ingress stack

The ingress stack is shared across many application stacks.

Responsibilities

The ingress stack must:

  • expose HTTP on :80
  • expose HTTPS on :443
  • resolve *.duckeighty.test locally
  • load a certificate for duckeighty.test and *.duckeighty.test
  • connect to a shared Docker network named shared_ingress

Required services

At minimum, the ingress stack should contain:

  • traefik
  • one DNS service such as dnsmasq or CoreDNS

Required Docker network

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.


Application integration

Each application remains a normal Compose application. Duckeighty integration is added with a local override file.

Assumptions

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

Override responsibilities

The local override file should:

  • attach the target service to shared_ingress
  • add Traefik labels
  • point Traefik to backend port 8080

Conceptual example

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_ingress

Labels 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.


Host setup

Duckeighty needs two host-side pieces:

  • local CA trust
  • local DNS routing for duckeighty.test

Local CA

The recommended approach is mkcert.

The setup flow should:

  1. ensure mkcert is installed

  2. run mkcert -install

  3. generate a certificate covering:

    • duckeighty.test
    • *.duckeighty.test
  4. 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

Local DNS on macOS

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).


CLI: duckeighty.sh

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.


Required Traefik behavior

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 web entrypoint on :80
  • a websecure entrypoint on :443
  • Docker provider enabled
  • file provider enabled

Required DNS behavior

The DNS service must answer local queries for:

  • *.duckeighty.test
  • optionally duckeighty.test

and return:

  • 127.0.0.1

The implementation may use either:

  • dnsmasq
  • CoreDNS

The initial version only needs one of them.


Example usage

1. Set up local CA

./duckeighty.sh setup-ca

2. Set up macOS resolver

./duckeighty.sh setup-resolver

3. Start ingress

./duckeighty.sh up

4. Start an app under a project name

COMPOSE_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 -d

5. Access the service

https://feature-foo-web.duckeighty.test

6. Start another worktree in parallel

NAME=feature-bar
COMPOSE_PROJECT_NAME="$NAME" DUCKEIGHTY_PROJECT_NAME="$NAME" \
  docker compose -f compose.yaml -f docker-compose.local.yaml up -d

Now both coexist:

  • https://feature-foo-web.duckeighty.test
  • https://feature-bar-web.duckeighty.test

Validation expectations

A correct implementation should make all of the following true:

  1. The ingress stack starts successfully.
  2. shared_ingress exists and is reusable.
  3. The wildcard certificate is trusted by the browser.
  4. *.duckeighty.test resolves locally to 127.0.0.1.
  5. An example app listening on 8080 becomes reachable through Traefik.
  6. The same app can be started twice with two project names.
  7. The two resulting hostnames route to two distinct environments.
  8. The application itself does not need branch-aware or worktree-aware logic.

Example app (examples/simple-app/)

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)

Testing guidance

A coding agent verifying duckeighty should check:

End-to-end

  1. ./duckeighty.sh up starts the duckeighty compose project with traefik + dnsmasq running.
  2. ./duckeighty.sh check <host>.duckeighty.test returns 127.0.0.1 from dig and a successful HTTPS status line (404 from Traefik when no app is yet registered for that host is the happy path).
  3. Bring up the example app twice under two NAME values and confirm each hostname returns its own project=<name> body.

Manual

  • no browser certificate warning on a *.duckeighty.test URL
  • curl -sI https://<host>.duckeighty.test/ returns a HTTP/2 status line and a Traefik-served response
  • stopping an app does not stop ingress
  • stopping ingress does not destroy app stacks

References

The following projects and ideas are directly relevant to duckeighty.

sptth

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.

mkcert

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

Traefik Docker provider

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

Docker Compose project isolation

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

Implementation notes for coding agents

When implementing duckeighty, prefer these choices:

  • keep the ingress stack separate from application stacks
  • treat shared_ingress as a reusable external network
  • generate a normalized DUCKEIGHTY_PROJECT_NAME outside 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.


Use from an AI coding agent

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.

Install via gh skill (recommended, cross-agent)

# 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 project

duckeighty-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.

Install via Claude Code plugin marketplace (Claude Code only)

/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.

Typical flow

  1. duckeighty-init — once per developer machine.
  2. duckeighty-integrate — once per app repo, creates docker-compose.local.yaml. Added to .git/info/exclude by default so it stays out of git status.
  3. 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.

About

No description or website provided.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors