# Community & support (/docs/community)

DockRoute is MIT licensed and developed in the open. Everything happens on the
repository: there is no private support channel, no forum and no chat to join.

<RepoStats className="my-8" />

## Where to take what [#where-to-take-what]

| You have | Take it to |
| --- | --- |
| A bug, a wrong record, a crash | [A bug report](https://github.com/Dockroute/Dockroute/issues/new/choose) |
| A question about labels or providers | [An issue](https://github.com/Dockroute/Dockroute/issues) — questions are welcome as issues |
| An idea for a feature or a new provider | An issue first, so the API shape can be discussed before the code |
| A security vulnerability | [Private vulnerability reporting](https://github.com/Dockroute/Dockroute/security/policy) — never a public issue |

## Filing an issue that gets fixed [#filing-an-issue-that-gets-fixed]

DockRoute reconciles state across Docker, a DNS provider and sometimes a
tunnel, so most reports come down to *which* of the three disagreed. Include:

- **The version** you are running (`docker inspect` the image tag, or the
  release tag).
- **The provider** and the sync policy in use — `sync`, `upsert-only` or
  `create-only` behave very differently around deletions.
- **The labels** on the container, verbatim. Redact the domain if you must,
  but keep the structure.
- **The logs** around the reconcile that went wrong. Run with the `log`
  provider if you can reproduce it there: it prints the plan without touching
  a zone.
- **What you expected** in the zone versus **what you found**.

An ownership question ("why did it skip my record?") is almost always answered
by the companion TXT record. Include it if there is one — see the
[safety model](/docs/concepts/safety-model).

## Sending a pull request [#sending-a-pull-request]

The [contributing guide](/docs/contributing) has the full workflow. In short:
ownership safety is non-negotiable, provider wire formats stay inside the
provider directory, and tests drive in-memory fakes rather than HTTP mocks.

Adding a DNS provider is the most useful contribution right now — the planner,
ownership rules and policy handling are already provider-agnostic, so a new
provider implements one interface and inherits all of it. Open a draft PR
early; API-shape discussions are much cheaper before the code hardens.

## Following releases [#following-releases]

<ReleaseBadge className="my-4" />

Releases are cut by CI on every merge to `main`, tagged `vX.Y.Z`, and published
as multi-arch images to `ghcr.io/dockroute/dockroute`. The
[changelog](/changelog) mirrors the release notes; watching the repository on
GitHub will mail you each one.

Pin a tag in production if you want to control upgrades — `latest` follows
`main`'s releases.

<OpenSourceActions className="my-8" />


# Architecture (/docs/concepts/architecture)

DockRoute is a short pipeline from the Docker socket to a DNS provider:

```text
Docker Engine API          events + list
/var/run/docker.sock  ───────────────────▶  Watcher
                                              │
                                              ▼
                                         Label Parser
                                              │
                                              ▼
                                        Desired State
                                (DNS records + tunnel routes)
                                              │
                                              ▼
                                          Reconciler
                                              │ sync
                                              ▼
                                        DNS Provider
                                    (log / cloudflare / ...)
```

1. **Watcher** — connects to the Docker socket. On startup it lists all
   running containers; afterwards it streams `/events` (container `start`,
   `die`, `stop`, `destroy`) and triggers a reconciliation on every relevant
   event. A periodic full re-list (`DOCKROUTE_RESYNC_SECONDS`) acts as a
   safety net against missed events.
2. **Label parser** — extracts `dockroute.*` labels from each container and
   turns them into desired state: plain DNS records or tunnel routes.
   Containers without `dockroute.enabled: "true"` are ignored.
3. **Reconciler** — merges the desired state from *all* running containers,
   deduplicating on hostname + record type (first container wins; a tunnel
   route always takes a hostname over a plain record), and hands it to the
   provider.
4. **Provider** — one interface, many implementations. The provider owns the
   diff: create, update, delete. `log` prints the computed desired state
   (dry run, the default); `cloudflare` manages records and tunnel routes;
   more are [welcome](/docs/contributing).

## Design decisions [#design-decisions]

- **Full-state sync, not incremental patches.** Every reconcile recomputes the
  complete desired set from the Docker API and syncs it. Simpler and
  self-healing — events only decide *when* to reconcile, never *what*.
- **Provider owns the diff, planner owns the rules.** Ownership and policy
  logic is provider-agnostic; each provider maps its wire format at its own
  boundary and never leaks it into the core (an anti-corruption layer).
- **No database.** Docker is the source of truth for desired state; the DNS
  provider plus the TXT registry is the source of truth for actual state and
  ownership. The container is stateless and disposable.
- **Convergence over cleverness.** Provider quirks (like Cloudflare forcing
  TTL `1` on proxied records) are normalized before diffing so reconciles
  settle instead of looping on no-op updates.
- **Deletes wait, creates do not.** A create propagates in seconds and is
  self-correcting; a delete leaves a failure cached at the DNS edge long after
  the container is back, and a restarting container is indistinguishable from a
  removed one. So deletions, and only deletions, have hysteresis
  (`DOCKROUTE_DELETE_GRACE_SECONDS`) — see
  [the safety model](/docs/concepts/safety-model).

For the full details — source layout included — see
[ARCHITECTURE.md](https://github.com/Dockroute/Dockroute/blob/main/ARCHITECTURE.md)
in the repository.


# Safety model (/docs/concepts/safety-model)

The central principle:

> **DockRoute never alters what it cannot prove it manages.**

A DNS zone usually holds more than your containers: MX records, records you
made by hand, records another tool manages. DockRoute's job is to converge its
slice of the zone without ever endangering the rest.

## The ownership TXT [#the-ownership-txt]

Modeled after ExternalDNS's TXT registry. Every data record DockRoute creates
gets a companion TXT record:

- **Name:** `<txt-prefix><record-type, lowercased>.<hostname>` — the A record for
  `whoami.example.com` is tracked by `_dockroute-a.whoami.example.com`. The
  prefix avoids CNAME-coexistence issues, and embedding the type
  disambiguates multiple record types at the same name.
- **Content:**
  `heritage=dockroute,dockroute/owner=<owner-id>[,dockroute/resource=container/<id>]`

A record is *owned* only when its companion TXT exists, carries
`heritage=dockroute` and matches this instance's `DOCKROUTE_OWNER_ID`.

## The rules [#the-rules]

Enforced by a provider-agnostic planner, so every provider behaves the same:

| Situation | What DockRoute does |
| --- | --- |
| Desired record, no existing record | Create record + ownership TXT |
| Existing record, owned, out of date | Update it |
| Existing record, **no ownership TXT** | **Conflict** — logged and skipped; never modified, never adopted |
| Existing record, owned by another id | Conflict — skipped; instances coexist safely |
| Owned record, container gone | Delete — only under the `sync` policy, and only after the grace window below |
| Dangling TXT, data record deleted by hand | Recreate the record if still desired; clean up the TXT under `sync` if not |

[Sync policies](/docs/configuration/policies) can restrict these actions
further; nothing can loosen them.

### Deletions wait [#deletions-wait]

Creates and updates are immediate; deletions are not. A record must be
continuously absent from the desired state for `DOCKROUTE_DELETE_GRACE_SECONDS`
(default 60) before its deletion is executed, and one that comes back inside
the window is never deleted at all.

The asymmetry is deliberate. A create propagates in seconds and is
self-correcting; a delete leaves a failure cached at the DNS edge and by every
resolver that asked, long after the container is back. And a container that is
merely restarting drops out of Docker's running set for a few seconds, looking
exactly like one that was removed for good.

A hostname that is still desired skips the window — it is being replaced, not
removed, as when a plain A record becomes a tunnel CNAME. Set the variable to
`0` to delete as soon as the container leaves.

## What this means in practice [#what-this-means-in-practice]

- Pre-existing records with the same hostname as a container are **not** taken
  over — you must delete them (or the label) yourself, deliberately.
- Deleting the ownership TXT by hand orphans the record from DockRoute's point
  of view: it becomes a conflict and is left alone.
- Multiple instances with distinct owner ids can share a zone — see
  [Multiple instances](/docs/guides/multiple-instances).
- The same proof applies to [Cloudflare Tunnel](/docs/guides/cloudflare-tunnel)
  ingress rules: only hostnames proven ours are ever rewritten.


# Environment variables (/docs/configuration/environment)

DockRoute is configured entirely through environment variables — there is no
config file and no database. The container is stateless and disposable.

## Core [#core]

| Variable                   | Default                | Description                             |
| -------------------------- | ---------------------- | --------------------------------------- |
| `DOCKER_SOCK`              | `/var/run/docker.sock` | Docker Engine socket path               |
| `DOCKROUTE_PROVIDER`       | `log`                  | `log` (dry-run) or `cloudflare`         |
| `DOCKROUTE_DEFAULT_TARGET` | —                      | Fallback record target when `dockroute.target` is omitted |
| `DOCKROUTE_RESYNC_SECONDS` | `60`                   | Interval of the periodic full reconcile |

## Ownership and policy [#ownership-and-policy]

| Variable                  | Default       | Description                                               |
| ------------------------- | ------------- | --------------------------------------------------------- |
| `DOCKROUTE_OWNER_ID`      | `default`     | Ownership id — lets several instances share a zone safely |
| `DOCKROUTE_POLICY`        | `sync`        | `sync`, `upsert-only` or `create-only` — see [Sync policies](/docs/configuration/policies) |
| `DOCKROUTE_DELETE_GRACE_SECONDS` | `60`    | Seconds a record must stay gone before it is deleted — `0` deletes at once |
| `DOCKROUTE_TXT_PREFIX`    | `_dockroute-` | Ownership TXT name prefix                                 |
| `DOCKROUTE_DOMAIN_FILTER` | —             | Comma-separated zone allowlist — entries are matched against provider **zone names**, not hostname suffixes |

<Callout title="Set a distinct owner id per instance">
  If two DockRoute instances ever share a zone with the same
  `DOCKROUTE_OWNER_ID`, each will treat the other's records as its own. Give
  every host a unique id (`home-lab`, `vps-1`, …) and they will coexist
  safely — see [Multiple instances](/docs/guides/multiple-instances).
</Callout>

## Cloudflare [#cloudflare]

| Variable                | Description                                                          |
| ----------------------- | -------------------------------------------------------------------- |
| `CLOUDFLARE_API_TOKEN`  | Token with **Zone → Zone → Read** + **Zone → DNS → Edit** (+ **Account → Cloudflare Tunnel → Edit** for tunnels) |
| `CLOUDFLARE_ACCOUNT_ID` | Required for tunnel publishing                                        |
| `CLOUDFLARE_TUNNEL_ID`  | Required for tunnel publishing (an existing tunnel — you run `cloudflared`) |

<Callout title="Fatal at startup">
  Some misconfigurations stop the container immediately instead of degrading
  silently: `DOCKROUTE_PROVIDER=cloudflare` without `CLOUDFLARE_API_TOKEN`, an
  invalid `DOCKROUTE_POLICY`, and a `DOCKROUTE_RESYNC_SECONDS` or
  `DOCKROUTE_DELETE_GRACE_SECONDS` that is not a number in range. A blank value
  is not an error — it means "unset" and falls back to the default, so clearing
  an optional field in Portainer or Unraid is safe. The container also refuses
  to start on an x86-64 CPU missing SSSE3/SSE4.1/SSE4.2/POPCNT — see
  [CPU requirements](/docs/quickstart#requirements).
</Callout>

## Example [#example]

```yaml title="compose.yaml"
services:
  dockroute:
    image: ghcr.io/dockroute/dockroute:latest
    environment:
      DOCKROUTE_PROVIDER: cloudflare
      DOCKROUTE_OWNER_ID: home-lab
      DOCKROUTE_POLICY: sync
      DOCKROUTE_DOMAIN_FILTER: example.com
      CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
```


# Labels (/docs/configuration/labels)

Labels are how a container opts into DNS management. DockRoute reads them from
every running container; anything without `dockroute.enabled: "true"` is
ignored.

| Label                          | Required | Default                    | Description                       |
| ------------------------------ | -------- | -------------------------- | --------------------------------- |
| `dockroute.enabled`            | yes      | —                          | `true` opts the container in      |
| `dockroute.hostname`           | yes      | —                          | FQDN(s), comma-separated          |
| `dockroute.type`               | no       | `A`                        | `A`, `AAAA` or `CNAME`            |
| `dockroute.target`             | no       | `DOCKROUTE_DEFAULT_TARGET` | Record value (IP or CNAME target) |
| `dockroute.ttl`                | no       | `300`                      | TTL in seconds                    |
| `dockroute.tunnel.service`     | no       | —                          | Origin URL; publish via Cloudflare Tunnel instead of a plain record |
| `dockroute.cloudflare.proxied` | no       | `false`                    | Proxy plain records through Cloudflare |

## Plain records [#plain-records]

The minimal setup is two labels; the record target comes from
`DOCKROUTE_DEFAULT_TARGET` when the label is omitted:

```yaml
services:
  grafana:
    image: grafana/grafana
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "grafana.example.com"
```

Everything is overridable per container:

```yaml
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "grafana.example.com,metrics.example.com"
      dockroute.type: "CNAME"
      dockroute.target: "host.example.com"
      dockroute.ttl: "120"
```

## Tunnel publishing [#tunnel-publishing]

`dockroute.tunnel.service` switches the container to
[Cloudflare Tunnel publishing](/docs/guides/cloudflare-tunnel) and takes
precedence over `dockroute.type`/`dockroute.target`:

```yaml
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "whoami.example.com"
      dockroute.tunnel.service: "http://whoami:80"
```

The value is the *origin* URL — how `cloudflared` reaches the service inside
your Docker network, usually `http://<service-name>:<port>`.

## Behavior notes [#behavior-notes]

- **Multiple hostnames** on one container each get their own record.
- **Duplicate hostname + record type** across containers: the first container
  wins; the duplicate is logged and skipped. Two containers *can* share a
  hostname with different types (one `A`, one `AAAA`), and a tunnel route
  always takes a hostname over a plain record.
- **Quoting matters** in Compose: write label values as strings
  (`"true"`, not `true`) so Docker does not coerce them.


# Sync policies (/docs/configuration/policies)

`DOCKROUTE_POLICY` bounds what a reconcile is allowed to do, with the same
semantics as ExternalDNS:

| Policy           | Creates | Updates owned | Deletes owned orphans |
| ---------------- | ------- | ------------- | --------------------- |
| `sync` (default) | ✓       | ✓             | ✓                     |
| `upsert-only`    | ✓       | ✓             | —                     |
| `create-only`    | ✓       | —             | —                     |

Two rules hold under **every** policy:

- Records DockRoute does not [own](/docs/concepts/safety-model) are never
  touched — conflicts are logged and skipped.
- Policies only *restrict*; they never grant extra behavior.

## Choosing a policy [#choosing-a-policy]

- **`sync`** — the intended mode. Containers appear, records appear;
  containers go away, their records go away. Use it once you trust your
  labels.
- **`upsert-only`** — records are created and kept current, but nothing is
  ever deleted. Good while migrating an existing zone, or when stopped
  containers should keep resolving.
- **`create-only`** — records are created once and then left alone. Useful
  when another system takes over records after creation.

## Orphan cleanup [#orphan-cleanup]

An *orphan* is a record whose container is gone but whose ownership TXT proves
it belongs to this instance. Under `sync`, orphans (and their TXTs) are removed
once they have been gone for `DOCKROUTE_DELETE_GRACE_SECONDS` (default 60), so a
container restart never takes the hostname down — see
[Deletions wait](/docs/concepts/safety-model). A dangling ownership TXT whose data record was deleted
by hand is also cleaned up under `sync` — or the record is recreated if its
container is still running.


# Contributing (/docs/contributing)

DockRoute is MIT licensed and built in the open. Issues and pull requests are
welcome at [github.com/Dockroute/Dockroute](https://github.com/Dockroute/Dockroute).

## Development [#development]

The project runs on [Bun](https://bun.sh):

```sh
bun install
bun test            # unit tests (in-memory fakes, no real HTTP)
bun run typecheck   # strict TypeScript
bun run lint        # Biome — bun run lint:fix to auto-fix
```

## Ground rules [#ground-rules]

Three properties are non-negotiable, in this order:

1. **Ownership safety.** Nothing may weaken the rule that unowned records are
   never modified, deleted or adopted. The planner's conflict handling is the
   heart of the project.
2. **Anti-corruption layer.** Provider wire formats stay inside the provider's
   own directory. The core deals only in DockRoute's own types.
3. **Testing style.** Tests use in-memory fakes, not HTTP mocks — a provider
   is tested by driving it against a fake API that enforces the real API's
   semantics.

See [CONTRIBUTING.md](https://github.com/Dockroute/Dockroute/blob/main/CONTRIBUTING.md)
for the full version.

## Adding a provider [#adding-a-provider]

Providers implement one interface and get the ownership planner for free —
`route53`, `rfc2136` and `pihole` are natural candidates. The rough shape:

1. Implement the provider interface — it is just `name` plus
   `sync(desiredState)`; listing current records and applying the diff are
   your provider's internals.
2. Map your provider's wire format at the boundary — never leak it into core
   types.
3. Reuse the provider-agnostic planner for ownership, policies, conflicts and
   orphan handling; it is already tested.
4. Register the provider with `registerProvider("<name>", factory)` and
   import the file from `src/index.ts` — an unimported provider is never
   registered.
5. Add an in-memory fake of the provider API and test against it.

The `cloudflare` provider is the reference implementation to crib from. Open a
draft PR early — API-shape discussions are easier before the code hardens.


# Cloudflare DNS (/docs/guides/cloudflare-dns)

The `cloudflare` provider reconciles plain DNS records against your Cloudflare
zones, with TXT-based ownership on every record it creates.

## Create the API token [#create-the-api-token]

In the Cloudflare dashboard, create a token with:

- **Zone → Zone → Read** — DockRoute lists your zones to map hostnames to
  them.
- **Zone → DNS → Edit** for the zones DockRoute should manage.

Scope the token to only those zones. If you will also use
[tunnel publishing](/docs/guides/cloudflare-tunnel), add
**Account → Cloudflare Tunnel → Edit**.

## Configure DockRoute [#configure-dockroute]

```yaml title="compose.yaml"
services:
  dockroute:
    image: ghcr.io/dockroute/dockroute:latest
    environment:
      DOCKROUTE_PROVIDER: cloudflare
      DOCKROUTE_OWNER_ID: home-lab
      DOCKROUTE_DOMAIN_FILTER: example.com
      CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
```

`DOCKROUTE_DOMAIN_FILTER` is optional but recommended: it is a hard allowlist
of the zones DockRoute may look at, independent of what the token can reach.
Entries are matched against **zone names** (`example.com`), not hostname
suffixes.

## Label your services [#label-your-services]

```yaml
  jellyfin:
    image: jellyfin/jellyfin
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "jellyfin.example.com"
      dockroute.target: "203.0.113.7"
```

## Proxied records [#proxied-records]

Set `dockroute.cloudflare.proxied: "true"` to serve a record through
Cloudflare's proxy (orange cloud):

```yaml
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "blog.example.com"
      dockroute.target: "203.0.113.7"
      dockroute.cloudflare.proxied: "true"
```

Cloudflare forces TTL to `1` (auto) on proxied records; DockRoute normalizes
both sides before diffing, so reconciles converge instead of issuing endless
no-op updates.

## What to expect in the zone [#what-to-expect-in-the-zone]

For each managed hostname you will see the data record plus a companion TXT,
e.g. `_dockroute-a.jellyfin.example.com` containing
`heritage=dockroute,dockroute/owner=home-lab,...`. Leave those TXTs alone —
they are DockRoute's [proof of ownership](/docs/concepts/safety-model).
Records that already existed in the zone are never modified or adopted.


# Cloudflare Tunnel (/docs/guides/cloudflare-tunnel)

Tunnel publishing lets a container behind NAT go public with a single label.
DockRoute does **not** create tunnels or move traffic — you create the tunnel
and run `cloudflared`; DockRoute manages, via the Cloudflare API:

1. the **public-hostname ingress rules** in the tunnel's remotely-managed
   configuration, and
2. the proxied **CNAME records** pointing each hostname at
   `<tunnel-id>.cfargotunnel.com`.

## Prerequisites [#prerequisites]

- An existing Cloudflare Tunnel (remotely managed) and a running `cloudflared`
  connector — typically a container in the same Compose stack.
- An API token with **Zone → Zone → Read**, **Zone → DNS → Edit** and
  **Account → Cloudflare Tunnel → Edit**.
- Your Cloudflare account id and the tunnel id.

## Configure [#configure]

```yaml title="compose.yaml"
services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel run
    environment:
      TUNNEL_TOKEN: ${TUNNEL_TOKEN}

  dockroute:
    image: ghcr.io/dockroute/dockroute:latest
    environment:
      DOCKROUTE_PROVIDER: cloudflare
      DOCKROUTE_OWNER_ID: home-lab
      CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
      CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID}
      CLOUDFLARE_TUNNEL_ID: ${CLOUDFLARE_TUNNEL_ID}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

  whoami:
    image: traefik/whoami
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "whoami.example.com"
      dockroute.tunnel.service: "http://whoami:80"
```

`dockroute.tunnel.service` is the origin URL — how `cloudflared` reaches the
service inside the Docker network. Tunnel mode takes precedence over plain
`dockroute.type`/`dockroute.target` labels.

## How DockRoute keeps the tunnel safe [#how-dockroute-keeps-the-tunnel-safe]

The Cloudflare tunnel-configuration endpoint replaces the *whole* ingress list
on every write, so DockRoute is deliberately conservative:

- An ingress rule is *managed* only if its hostname is proven ours through the
  TXT registry. Everything else is preserved verbatim, in its original order,
  ahead of managed rules — a pre-existing rule never starts being shadowed.
- An unmanaged rule already claiming a desired hostname is a conflict: logged
  and skipped.
- The catch-all rule (last, no hostname) is always preserved; a fresh ingress
  gets `http_status:404`.
- The configuration is re-read immediately before writing and only written
  when it actually changed.

<Callout type="warn" title="One automated writer per tunnel">
  The endpoint is last-writer-wins. DockRoute assumes it is the only automated
  writer for the tunnels it manages — don't point another sync tool at the
  same tunnel.
</Callout>


# Migrating an existing record (/docs/guides/migrating-to-dockroute)

DockRoute never adopts a record it cannot prove it owns — see the
[safety model](/docs/concepts/safety-model). So the first time you label a
container whose hostname already resolves to something else, nothing breaks
and nothing updates: DockRoute logs a conflict and leaves the existing record
alone.

## Recognize the conflict in your logs [#recognize-the-conflict-in-your-logs]

Depending on what already holds the hostname, you'll see one of these:

```text title="no ownership TXT at all (manual record, DockFlare, anything else)"
[cloudflare] conflict on A app.example.com: record exists but has no ownership TXT — not managed by dockroute — skipping
```

```text title="TXT exists but belongs to a different owner id"
[cloudflare] conflict on A app.example.com: record is owned by "other-id", not "default" — skipping
```

```text title="a TXT from a different owner id exists, but its data record is gone"
[cloudflare] conflict on A app.example.com: hostname is claimed by owner "other-id" (dangling ownership TXT) — skipping
```

```text title="tunnel: an existing ingress rule (e.g. from DockFlare) already claims the hostname"
[cloudflare] tunnel route app.example.com: an unmanaged ingress rule claims this hostname — skipping
```

None of these touch the existing record or ingress rule. DockRoute keeps
retrying on every reconcile, so once the conflict is resolved it picks the
hostname up on its own.

## Default path: delete, then let DockRoute recreate it [#default-path-delete-then-let-dockroute-recreate-it]

This is the recommended way to migrate — a clean handover, at the cost of a
few seconds without resolution.

1. **Deploy DockRoute with the container's labels already in place.** It logs
   the conflict above and changes nothing — safe to do first.
2. **Stop whatever currently manages the hostname** for that container —
   disable DockFlare for it, or stop writing to the record by hand. If you
   skip this, the old tool recreates what you just deleted.
3. **Delete the existing record in the Cloudflare dashboard.** For a tunnel
   hostname, also delete the old ingress rule for it.
4. **Wait for the next reconcile.** It runs immediately on the next container
   event, or at most `DOCKROUTE_RESYNC_SECONDS` later (default 60). DockRoute
   creates the record (or ingress rule) and its ownership TXT.

<Callout type="info" title="A few seconds of downtime">
  The hostname won't resolve between step 3 and the next reconcile — at most
  `DOCKROUTE_RESYNC_SECONDS`, often less since container start/stop events
  trigger an immediate reconcile. Plan the cutover for a quiet moment if that
  gap matters.
</Callout>

## Advanced: zero-downtime adoption [#advanced-zero-downtime-adoption]

You can skip the gap by creating the ownership TXT yourself, before deleting
anything. The domain owner asserting "DockRoute manages this" is exactly what
the TXT proves, so this doesn't weaken the ownership check — but it does
carry real risk, so read both warnings below before using it.

<Callout type="warn" title="Read before using">
  - Under the default `sync` policy, a record adopted this way **will be
    deleted** once its container is gone (after the delete grace period,
    `DOCKROUTE_DELETE_GRACE_SECONDS`) — same as any other DockRoute record.
    Use [`upsert-only`](/docs/configuration/policies) while adopting if
    you're not ready for that.
  - If the existing record's content, TTL or proxied setting differs from
    what the container's labels produce, DockRoute **overwrites the record**
    to match the labels on the next reconcile (unless the policy is
    `create-only`).
</Callout>

This only applies to DNS records (A/AAAA/CNAME). A tunnel ingress hostname
always follows the default path above — DockRoute only treats a tunnel
hostname as its own once a CNAME for it already points at its own
`<tunnel-id>.cfargotunnel.com`, which a TXT record alone can't establish.

1. In Cloudflare, create a TXT record named `_dockroute-<type>.<hostname>`
   (lowercased type — `_dockroute-a.app.example.com` for an `A` record;
   replace `_dockroute-` with your `DOCKROUTE_TXT_PREFIX` if you set one)
   with content `heritage=dockroute,dockroute/owner=default` (replace
   `default` with your `DOCKROUTE_OWNER_ID` if you set one).
2. Deploy the container with its DockRoute labels. The existing record now
   reads as owned, so DockRoute reconciles it in place — no gap.
3. Once the record matches what you expect, switch back to `sync` if you
   changed the policy for the adoption.

This is not the default recommendation — prefer the delete-and-recreate path
above unless the downtime is genuinely unacceptable for that hostname.


# Multiple instances (/docs/guides/multiple-instances)

Several DockRoute instances — one per Docker host, say — can share a single
DNS zone. Safety comes from the [ownership registry](/docs/concepts/safety-model):
every record carries its creator's `DOCKROUTE_OWNER_ID` in a companion TXT,
and an instance only ever touches records that carry *its* id.

## Setup [#setup]

Give each host a unique owner id:

```yaml title="host A"
  dockroute:
    environment:
      DOCKROUTE_OWNER_ID: home-lab
```

```yaml title="host B"
  dockroute:
    environment:
      DOCKROUTE_OWNER_ID: vps-1
```

That's it. Each instance reconciles only its own containers' records; records
owned by the other id are treated as conflicts and skipped.

<Callout type="warn" title="Never reuse an owner id">
  Two instances sharing one id will fight over each other's records — each
  sees the other's containers as "gone" and, under `sync`, deletes their
  records. Unique ids per instance is the rule.
</Callout>

## Same hostname on two hosts [#same-hostname-on-two-hosts]

Ownership is first-come: whichever instance creates
`app.example.com` first owns it; the other logs a conflict and skips. DNS-level
round-robin across hosts is not supported — point the hostname at one owner.

## Scoping instances to zones [#scoping-instances-to-zones]

Combine owner ids with `DOCKROUTE_DOMAIN_FILTER` to partition responsibility:

```yaml
  dockroute:
    environment:
      DOCKROUTE_OWNER_ID: vps-1
      DOCKROUTE_DOMAIN_FILTER: example.com
```

The filter is a hard allowlist — zones outside it are invisible to that
instance, regardless of what the API token can reach.

<Callout type="warn" title="The filter matches zone names, not hostnames">
  Each entry must be a Cloudflare **zone** name (`example.com`), not an
  arbitrary subdomain. `lab.example.com` only matches if `lab.example.com` is
  itself a zone in your account; if it is just a subdomain inside the
  `example.com` zone, no zone matches and the instance manages nothing.
</Callout>

## Migrating an owner id [#migrating-an-owner-id]

Renaming an owner id makes existing records look foreign: they carry the old
id in their ownership TXT, so the renamed instance treats them as conflicts
and skips them — nothing is deleted, but nothing is updated either. To
migrate, either delete the old record + TXT pairs and let DockRoute recreate
them under the new id, or hand-edit each TXT's `dockroute/owner=` value to
the new id.


# Introduction (/docs)

<img
  src="/brand/dockroute-mascot-pip.webp"
  alt="Pip, the DockRoute mascot — a puffin harbor pilot"
  width="220"
  height="220"
/>

DockRoute watches your running Docker containers, reads `dockroute.*` labels and
reconciles the matching DNS records — and Cloudflare Tunnel routes — in a
pluggable provider. It is the same idea as Kubernetes
[ExternalDNS](https://github.com/kubernetes-sigs/external-dns), applied where most
homelabs and small stacks actually live: plain Docker and Docker Compose hosts.

Your Compose file is the source of truth. DockRoute makes the provider match it,
and **never alters what it cannot prove it manages**.

<RepoStats className="my-8" />

## What it does [#what-it-does]

- Streams container lifecycle events (`start`, `die`, `stop`, `destroy`) from
  the Docker socket and keeps a periodic full resync as a safety net.
- Turns `dockroute.hostname` labels into `A`, `AAAA` or `CNAME` records — or
  into Cloudflare Tunnel public hostnames with `dockroute.tunnel.service`.
- Proves ownership of every record it creates with a companion TXT record, so
  it can share a zone with your hand-managed records and with other DockRoute
  instances without conflict.
- Cleans up records when their container goes away — only under the `sync`
  policy, and only for records it owns.

## What it does not do [#what-it-does-not-do]

- It does not proxy or route traffic. DockRoute manages *names*, not packets —
  pair it with your reverse proxy or an existing Cloudflare Tunnel.
- It does not create tunnels. You create the tunnel and run `cloudflared`;
  DockRoute manages the tunnel's public-hostname ingress rules and CNAMEs.
- It does not need a database. Docker is the desired state; the provider plus
  the TXT registry is the actual state.

## Where to go next [#where-to-go-next]

<Cards>
  <Card
    title="Quickstart"
    description="From zero to reconciled records with a dry run first."
    href="/docs/quickstart"
  />
  <Card
    title="Compose generator"
    description="Answer a few questions, get a ready-to-use compose.yaml and .env."
    href="/generator"
  />
  <Card
    title="Label reference"
    description="Every dockroute.* label and its defaults."
    href="/docs/configuration/labels"
  />
  <Card
    title="Safety model"
    description="How TXT ownership keeps your zone safe."
    href="/docs/concepts/safety-model"
  />
  <Card
    title="Cloudflare Tunnel"
    description="Publish homelab services with no open ports."
    href="/docs/guides/cloudflare-tunnel"
  />
  <Card
    title="Community & support"
    description="Where to report a bug, ask a question or send a patch."
    href="/docs/community"
  />
</Cards>


# Quickstart (/docs/quickstart)

<Callout title="Prefer a form to a blank file?">
  The [compose generator](/generator) builds a `compose.yaml` and `.env` for
  your setup — provider, optional Cloudflare Tunnel, optional example
  service — entirely in your browser.
</Callout>

## Requirements [#requirements]

DockRoute runs on `linux/amd64` and `linux/arm64`. On x86-64, the Bun runtime
needs a CPU that reports **SSSE3**, **SSE4.1**, **SSE4.2** and **POPCNT**
(the `x86-64-v2` microarchitecture level) — true for Intel CPUs since
Nehalem (2008) and AMD since Bulldozer (2011), but not for older Atoms
(pre-2013), Core 2 or Phenom II/Athlon II, nor for a VM pinned to a generic
model such as QEMU's `kvm64`/`qemu64`. arm64 is not affected.

<Callout type="warn" title="dockroute: unsupported CPU, missing ssse3 sse4_1 sse4_2 popcnt">
  Without SSSE3/SSE4.1/SSE4.2/POPCNT, Bun usually hangs at 100% CPU with no
  log output (or crashes with SIGILL) instead of exiting cleanly — so the
  entrypoint checks `/proc/cpuinfo` before starting the app and exits right
  away with this message, listing only the flags that are actually missing.
  In a VM (Proxmox, libvirt, generic QEMU), set the CPU type to `host`
  (`host-passthrough` in libvirt) or `x86-64-v2` or newer, then restart
  the VM.
</Callout>

## 1. Dry run — no credentials needed [#1-dry-run--no-credentials-needed]

DockRoute defaults to the `log` provider, which runs the whole pipeline and
prints the desired state it computes instead of touching a zone. It is the
safest way to check your labels.

```yaml title="compose.yaml"
services:
  whoami:
    image: traefik/whoami
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "whoami.example.com"

  dockroute:
    image: ghcr.io/dockroute/dockroute:latest
    environment:
      DOCKROUTE_DEFAULT_TARGET: 192.168.1.10
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
```

```sh
docker compose up
```

You should see the desired state logged: an `A` record for
`whoami.example.com` pointing at `192.168.1.10`.

<Callout title="Docker socket permissions">
  Handled automatically: the entrypoint detects the socket's group, grants it
  to the app user and drops privileges before starting — the process itself
  runs as non-root. If you set an explicit `user:` override, add the socket's
  group yourself with `group_add: ["<gid>"]`
  (`stat -c '%g' /var/run/docker.sock`).
</Callout>

## 2. Go live with Cloudflare [#2-go-live-with-cloudflare]

Switch the provider and add a token with **Zone → Zone → Read** and
**Zone → DNS → Edit** permissions:

```yaml title="compose.yaml"
  dockroute:
    image: ghcr.io/dockroute/dockroute:latest
    environment:
      DOCKROUTE_PROVIDER: cloudflare
      DOCKROUTE_OWNER_ID: home-lab
      DOCKROUTE_DEFAULT_TARGET: 192.168.1.10
      CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
```

Restart the stack. DockRoute lists your running containers, computes the
desired records and reconciles them. From now on, every container `start` and
`stop` triggers a reconcile, and a full resync runs every 60 seconds.

## 3. Publish through a Cloudflare Tunnel (optional) [#3-publish-through-a-cloudflare-tunnel-optional]

If you already run `cloudflared` with a tunnel, one extra label publishes a
service through it — no port forwarding at all:

```yaml
  whoami:
    image: traefik/whoami
    labels:
      dockroute.enabled: "true"
      dockroute.hostname: "whoami.example.com"
      dockroute.tunnel.service: "http://whoami:80"
```

The tunnel needs two more environment variables on the DockRoute container
(`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_TUNNEL_ID`) and the token needs
**Account → Cloudflare Tunnel → Edit**. See the
[Cloudflare Tunnel guide](/docs/guides/cloudflare-tunnel) for the full setup.

## Verify [#verify]

- `docker compose logs dockroute` shows every create, update, delete and
  skipped conflict.
- Your zone now contains the record *and* a companion TXT like
  `_dockroute-a.whoami.example.com` — that TXT is DockRoute's
  [proof of ownership](/docs/concepts/safety-model).
- Stop the `whoami` container: under the default `sync` policy both records
  are removed once it has been gone for `DOCKROUTE_DELETE_GRACE_SECONDS`
  (60 by default), so a restart never takes the hostname down.
