hafenmeister
v0.1.0
Published
Spin up local Docker Compose environments with routed hostnames
Maintainers
Readme
hafenmeister
Spin up local Docker Compose environments with routed hostnames.
hafenmeister wraps Docker Compose with a shared Traefik reverse
proxy and a dnsmasq wildcard resolver, so
each project (or branch, or git worktree) comes up under its own domain — e.g.
web.myproject.test — without editing /etc/hosts or remembering port numbers.
You describe your services in an ordinary compose.yml and add a small hafenmeister
config that says which services to route and on which ports. hm up then:
- brings up a shared infra stack (Traefik + DNS) once, reused across every project;
- derives a slug for the environment from the directory name (or
--slug); - selects the requested services (plus their
depends_onclosure), injects Traefik routing labels, and starts them on the shared network; - prints the URLs each routed service is reachable at.
Install
Requires Node.js ≥ 20 and Docker (or a drop-in like Podman).
npm install -g hafenmeisterThis installs the hm command.
Quick start
Add a
hafenmeister.ts(or.js) next to your compose file:import { defineProjectConfig } from "hafenmeister/config"; export default defineProjectConfig({ composeFile: "./compose.yml", // start `web` on a bare `hm up` (no need to name it) alwaysOn: ["web"], // expose `web` (which listens on 3000) through Traefik routes: { web: 3000 }, });Bring it up:
hm uphafenmeister: infra is up hafenmeister: myproject up — web http://web.myproject.testTear it down when you're done:
hm down
For DNS to resolve *.test to Traefik, point your resolver at the dnsmasq instance
hafenmeister runs (default: 127.0.0.1:5053). On macOS the usual approach is a
/etc/resolver/test file:
nameserver 127.0.0.1
port 5053Commands
hm up [services...]
Bring up the shared infra and the named services. With no services named, only the
alwaysOn services (and the infra) start.
| Option | Description |
| --- | --- |
| services... | Service names to start — must match names in the compose file. Their depends_on services are pulled in automatically. |
| -p, --path <path> | Path to the project. hafenmeister recurses upward to find the project config. Defaults to the current directory. |
| -s, --slug <slug> | Slug identifying the environment. Derived from the project directory name otherwise. |
| -d, --dry-run | Print the computed infra and project compose files to stdout without applying them. |
hm down
Tear down the project's stack. The shared infra is left running (it's shared across
projects) unless --infra is passed.
| Option | Description |
| --- | --- |
| -p, --path <path> | Path to the project (recurses upward to find the config). |
| -s, --slug <slug> | Environment slug to tear down. Derived from the project directory otherwise. |
| --infra | Also tear down the shared infra stack (Traefik, dnsmasq, etc.). |
How domains are built
Each routed service is reachable at <host>.<domain>, where <host> defaults to the
service name (overridable per route) and <domain> is assembled from:
<slug>[.<machine-host>][.<project-domain>].<localTld>- slug — derived from the project directory, or set with
--slug. This is what gives each branch/worktree its own domain. - machine-host — the host's short name, included only when
includeMachineHostis on (default off). - project-domain — the optional
domainfield in the project config. - localTld — the machine-wide TLD,
testby default.
So a project in ~/code/myproject with domain: "tester" and a web route resolves to
web.myproject.tester.test.
Project config
The project config lives at hafenmeister.ts/hafenmeister.js in (or above) your
project. Use defineProjectConfig for type checking.
| Field | Description |
| --- | --- |
| composeFile | Path to your compose file, or a function returning the parsed compose object. (Required) |
| routes | HTTP routes to expose through Traefik, keyed by service name. See below. A service not listed still starts if requested — hafenmeister never guesses ports. |
| alwaysOn | Services to start on every hm up, regardless of what's named. |
| domain | Extra label folded into the hosted domain (see above). |
| env | Environment for the services: true (default) loads .env, false disables, or pass a Record<string, string> or a function returning one. |
| localEnvFile | Local env file applied last (so it overrides env). Defaults to .env.local; set false to skip. |
Routes
routes: {
web: 3000, // web.<DOMAIN> → port 3000
api: { port: 8080, host: "graph" }, // graph.<DOMAIN> → port 8080
admin: [ // one service, several routes
{ port: 3000, host: "admin" },
{ port: 9000, host: "admin-metrics" },
],
}Hostnames default to the service name and must all be unique. DOMAIN and SLUG are
also injected into the compose environment, so your services can reference them.
Machine config
The machine config is shared across all projects and normally lives at
~/.config/hafenmeister (hafenmeister searches upward from the project for
hafenmeister.machine.ts/.js). Every field is optional; the defaults below are what
you get with no config at all.
| Field | Default | Description |
| --- | --- | --- |
| localTld | "test" | The TLD every service is hosted under. |
| network | "hafenmeister" | Name of the Docker network Traefik and the project services share. |
| http | { port: 80 } | Traefik HTTP listener. Set false to disable. |
| https | off | Traefik HTTPS listener — { port?, cert, key }. When set, services are served over HTTPS with the given cert. |
| dns | loopback :5053 | DNS resolvers, each answering *.<localTld> on a port (see below). false disables DNS. |
| includeMachineHost | false | Fold the host's short name into service domains. |
| infraComposeFile | none | Extra compose file whose services are merged into the shared infra stack — for machine-wide services every project shares, like a database. |
| docker.command | "docker" | The Docker CLI to invoke (e.g. "podman"). |
DNS resolvers
The default is a single loopback resolver (127.0.0.0/8 on port 5053) for this host's
browser. Setting dns replaces the default, so include the loopback entry if you
still want it. Add more resolvers to reach services from elsewhere:
import { defineMachineConfig } from "hafenmeister/config";
export default defineMachineConfig({
dns: [
{ port: 5053, subnet: "127.0.0.0/8" }, // this host's browser
{ port: 53, subnet: "100.64.0.0/10" }, // a Tailscale tailnet
{ port: 5354, answer: "10.0.2.2" }, // the Android emulator
],
});subnetfinds the host's IP within that CIDR, binds the resolver to it, and answers with that IP.answerbinds to loopback and answers with the fixed IP given.
Shared infra services
infraComposeFile lets you run machine-wide services — a shared Postgres, say — once
and have every project reach them. The services are joined to the shared network, so a
routed project service can reach them by name (postgres:5432). Service names can't
collide with the built-ins (traefik, the DNS resolvers).
Development
npm install
npm run build # compile to dist/
npm run dev # compile in watch mode
npm run typecheck # type-check without emitting
npm test # run the vitest suiteLicense
MIT
