Skip to content

probycode/anchor

Repository files navigation

Anchor

Build License: MIT GHCR

A Docker → Cloudflare DNS sidecar. Anchor watches your Docker hosts and a list of static devices, then automatically manages Cloudflare DNS records and Cloudflare Tunnel ingress rules based on simple container labels. Optional integration with Uptime Kuma auto-creates and removes monitors as services come and go.

Built with .NET 10 (ASP.NET Core API) and a React + Vite UI, packaged as a single Docker image.

Table of contents

Features

  • Watches one or more Docker hosts (local socket or remote tcp://)
  • Creates / updates / deletes Cloudflare DNS records as containers start and stop
  • Manages Cloudflare Tunnel ingress rules for external services
  • Supports static (non-Docker) devices like printers, NAS, routers
  • Optional Uptime Kuma monitor sync via labels
  • Web UI for status, services, and event history
  • Optional Grafana Loki log sink
  • Orphan grace period to avoid flapping during deploys
  • Manual "Sync Now" button to trigger reconciliation on demand

How it works

You add a couple of labels to a container. Anchor sees the container start, parses the labels, and creates the right records on Cloudflare:

  • anchor.access=internal → an A record pointing at your LAN IP. Traffic stays on your network.
  • anchor.access=external → a CNAME to your Cloudflare Tunnel plus an ingress rule that forwards <name>.<domain> to a backend URL of your choice. Traffic enters via Cloudflare's edge.

When the container stops, Anchor waits a grace period (so you don't lose records on a restart) and then removes them.

Anchor itself does not do TLS, routing, or reverse proxying. If you want HTTPS on your LAN and Host-header routing to many containers, run Traefik alongside Anchor — that's the setup in Option A.

Prerequisites

  • A domain on Cloudflare (free plan is fine)
  • Docker and Docker Compose on the host that will run Anchor
  • A Cloudflare account where you can create API tokens and a Tunnel

Cloudflare setup

You only do this once.

1. Create an API token

  1. Go to My Profile → API Tokenshttps://dash.cloudflare.com/profile/api-tokens
  2. Click Create TokenCreate Custom Token
  3. Token name: anchor
  4. Permissions:
    • ZoneDNSEdit
    • AccountCloudflare TunnelEdit
  5. Zone Resources: Include → Specific zone → (your domain)
  6. Account Resources: Include → (your account)
  7. Click Continue to summaryCreate Token
  8. Copy the token now — Cloudflare won't show it again. This is your CF_API_TOKEN.

2. Get your Account ID

  1. Open the Cloudflare dashboard for your domain
  2. In the right sidebar, find Account ID and copy it
  3. This is your CF_ACCOUNT_ID

3. Create a Cloudflare Tunnel

You need this even if you only plan to run internal services — Anchor expects a tunnel ID. (If you genuinely never want any external services, you can skip the tunnel and remove the cloudflared service from compose; see Option B.)

  1. Go to Zero Trusthttps://one.dash.cloudflare.com/
  2. Networks → Tunnels → Create a tunnel
  3. Connector: CloudflaredNext
  4. Tunnel name: anchor (or whatever you like) → Save tunnel
  5. On the Install connector screen, don't run the install command — instead copy the long token string from it. This is your CF_TUNNEL_TOKEN.
  6. Click Next through the routing screens (Anchor will populate them) and Finish.
  7. Back on the Tunnels list, click your tunnel. The URL contains the tunnel UUID (.../tunnels/<UUID>). That's your CF_TUNNEL_ID.

Install

Pick one of the two options below depending on whether you want automatic HTTPS.

Option A — Full stack with HTTPS (recommended)

This bundles Anchor + Traefik + cloudflared into one compose file. You get:

  • Automatic Cloudflare DNS records (Anchor)
  • Wildcard TLS cert via Let's Encrypt DNS-01 challenge (Traefik)
  • Host-header routing to all your containers (Traefik)
  • Cloudflare Tunnel for external services (cloudflared)

1. Create the shared Docker network (one-time):

docker network create web

2. Download the compose file and env template:

curl -O https://raw.githubusercontent.com/probycode/anchor/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/probycode/anchor/main/.env.example

3. Fill in .env with the values you collected in Cloudflare setup:

CF_API_TOKEN=...
CF_ACCOUNT_ID=...
CF_TUNNEL_ID=...
CF_TUNNEL_TOKEN=...
ANCHOR_BASE_DOMAIN=example.com
ANCHOR_LOCAL_IP=192.168.1.10
ACME_EMAIL=you@example.com

4. Start the stack:

docker compose up -d

5. Open the UI: https://anchor.example.com (or whatever you set ANCHOR_BASE_DOMAIN to). On first start it may take a minute for Traefik to finish issuing the wildcard cert.

You're done. Now add services by labeling containers.

Option B — Minimal, no reverse proxy

Use this if you just want Cloudflare DNS records managed from container labels and you're fine with plain HTTP on your LAN. No Traefik, no automatic TLS.

curl -O https://raw.githubusercontent.com/probycode/anchor/main/docker-compose.minimal.yml
curl -o .env https://raw.githubusercontent.com/probycode/anchor/main/.env.example
# fill in .env
docker compose -f docker-compose.minimal.yml up -d

UI: http://localhost:3000

Internal services will be reachable at http://<container-host-ip>:<port> once their A records propagate. External services will need either a per-container anchor.tunnel.service label or a global ANCHOR_TUNNEL_SERVICE env var pointing at the backend you want each tunnel ingress rule to forward to.

Adding a new service

In Option A, every service needs two sets of labels: anchor.* tells Anchor what DNS records to create, and traefik.* tells Traefik how to route the traffic.

In Option B, you only need the anchor.* labels.

Internal service

LAN-only — Cloudflare resolves the name to your local IP, and traffic never leaves your network.

services:
  grafana:
    image: grafana/grafana
    networks: [web]
    labels:
      # Anchor
      anchor.name: "grafana"
      anchor.access: "internal"
      # Traefik (Option A only)
      traefik.enable: "true"
      traefik.http.routers.grafana.rule: "Host(`grafana.example.com`)"
      traefik.http.services.grafana.loadbalancer.server.port: "3000"

Reach it at https://grafana.example.com from any device on your LAN.

External service

Reachable from anywhere via Cloudflare Tunnel. Same Traefik labels as before — only anchor.access changes.

services:
  uptime-kuma:
    image: louislam/uptime-kuma
    networks: [web]
    labels:
      anchor.name: "kuma"
      anchor.access: "external"
      traefik.enable: "true"
      traefik.http.routers.kuma.rule: "Host(`kuma.example.com`)"
      traefik.http.services.kuma.loadbalancer.server.port: "3001"

Reach it at https://kuma.example.com from anywhere.

Static (non-Docker) device

For devices you can't put labels on (printers, NAS, routers, bare-metal services). Set ANCHOR_STATIC_SERVICES on the Anchor container as a JSON array:

ANCHOR_STATIC_SERVICES: >
  [
    { "name": "printer", "access": "internal", "ip": "192.168.1.20" },
    { "name": "nas",     "access": "internal", "ip": "192.168.1.21" }
  ]

Anchor will create A records for printer.example.com and nas.example.com.

Optional integrations

Uptime Kuma

Anchor can automatically create and remove Uptime Kuma monitors as your services come and go — no clicking around in the Kuma UI when you add a container.

1. Add Kuma to your stack (or point Anchor at an existing Kuma):

  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      - ./kuma-data:/app/data
    networks: [web]
    labels:
      anchor.name: "kuma"
      anchor.access: "internal"
      traefik.enable: "true"
      traefik.http.routers.kuma.rule: "Host(`kuma.example.com`)"
      traefik.http.services.kuma.loadbalancer.server.port: "3001"

2. Open https://kuma.example.com and finish the first-run setup (create an admin user).

3. Add the Kuma credentials to Anchor's environment:

  anchor:
    environment:
      # ...existing vars...
      ANCHOR_UPTIME_KUMA_URL: http://uptime-kuma:3001
      ANCHOR_UPTIME_KUMA_USERNAME: admin
      ANCHOR_UPTIME_KUMA_PASSWORD: ${UPTIME_KUMA_PASSWORD}

Add UPTIME_KUMA_PASSWORD=... to your .env and docker compose up -d anchor to restart Anchor.

4. Label any container you want monitored:

  grafana:
    labels:
      anchor.name: "grafana"
      anchor.access: "internal"
      # Kuma monitor
      anchor.monitor.type: "http"
      anchor.monitor.url: "https://grafana.example.com"
      anchor.monitor.interval: "60"
      anchor.monitor.retries: "2"

When the container starts, Anchor creates a matching monitor in Kuma. When it stops (past the orphan grace period), Anchor deletes the monitor. You can also use anchor.access=none on a container if you want a Kuma monitor without any DNS record (e.g. monitor a service that's reached via a different host).

Notification IDs: if you've set up notifications in Kuma, find their numeric IDs in the Kuma UI (Settings → Notifications) and pass a comma-separated list as anchor.monitor.notifications, e.g. "1,3".

Grafana Loki (log sink)

Anchor uses Serilog and can ship its logs to a Loki instance. Set LOKI_URL on the Anchor container:

  anchor:
    environment:
      LOKI_URL: http://loki:3100

That's it — no extra config. Logs are tagged with app=anchor and structured fields for service name, host, event type, etc., so you can build Grafana dashboards on top of them.

Watching multiple Docker hosts

By default Anchor watches the local Docker socket. You can point it at multiple hosts (local + remote) by setting ANCHOR_HOSTS as a JSON array:

  anchor:
    environment:
      ANCHOR_HOSTS: >
        [
          { "name": "local",  "socket": "unix:///var/run/docker.sock" },
          { "name": "node-2", "socket": "tcp://192.168.1.11:2375" },
          { "name": "node-3", "socket": "tcp://192.168.1.12:2375" }
        ]

Each remote host needs to expose the Docker API on tcp://. This is unauthenticated by default — only do this on a trusted LAN, or use Docker's TLS protection to require client certs. Containers on every listed host show up in the Anchor UI tagged with the host name.

Container labels reference

Label Required Description
anchor.name yes Subdomain to create (e.g. grafanagrafana.example.com)
anchor.access yes internal (LAN A record), external (Cloudflare Tunnel), or none (track-only, for monitor-only services)
anchor.domain no Full domain override instead of <name>.<base-domain>
anchor.tunnel.service no Override tunnel ingress target for external services (defaults to ANCHOR_TUNNEL_SERVICE)

Optional Uptime Kuma monitor labels

Label Description
anchor.monitor.type Monitor type (e.g. http, ping)
anchor.monitor.url URL or host to probe
anchor.monitor.interval Check interval in seconds
anchor.monitor.retries Retry count before marking down
anchor.monitor.notifications Comma-separated notification IDs

Configuration reference

All configuration is via environment variables on the anchor container:

Variable Required Default Description
ANCHOR_BASE_DOMAIN yes Base domain for generated records
ANCHOR_LOCAL_IP yes LAN IP used for internal A records
CF_API_TOKEN yes Cloudflare API token
CF_ACCOUNT_ID yes Cloudflare account ID
CF_TUNNEL_ID yes Cloudflare Tunnel ID
CF_TUNNEL_NAME no Tunnel name (cosmetic)
ANCHOR_HOSTS no local socket JSON array of Docker hosts to watch
ANCHOR_STATIC_SERVICES no [] JSON array of static (non-Docker) services
ANCHOR_TUNNEL_SERVICE no http://traefik:80 Default tunnel ingress target
ANCHOR_SYNC_INTERVAL no 60 Reconciliation interval (seconds)
ANCHOR_ORPHAN_GRACE_PERIOD no 300 Seconds to wait before removing records for stopped containers
ANCHOR_LOG_LEVEL no info debug / info / warn / error
ANCHOR_UI_PORT no 3000 UI / API port
ANCHOR_UPTIME_KUMA_URL no Enables Uptime Kuma integration if set
ANCHOR_UPTIME_KUMA_USERNAME no Uptime Kuma username
ANCHOR_UPTIME_KUMA_PASSWORD no Uptime Kuma password
LOKI_URL no Optional Grafana Loki sink

Building from source

docker build -t anchor:local .

The Dockerfile builds the React UI and the .NET API in separate stages and produces a single ~55MB Alpine-based image.

Development

Run the API and UI separately for fast iteration:

# UI (http://localhost:5173, proxied to the API)
cd ui
npm install
npm run dev

# API (http://localhost:3000)
cd src/Anchor.Api
dotnet run

Troubleshooting

https://anchor.example.com shows a cert error Traefik is still issuing the wildcard cert. Watch docker logs traefik — you should see the DNS-01 challenge complete within a minute or two. If it fails, double-check that CF_API_TOKEN has Zone:DNS:Edit permission on the right zone.

Internal hostname doesn't resolve Anchor created the record on Cloudflare, but your device may be caching DNS. Try nslookup grafana.example.com 1.1.1.1 to bypass the cache. Also check the Anchor UI's Events tab to confirm the record was created.

External hostname returns a 502 The tunnel is up but cloudflared can't reach the backend. Make sure the target you set in ANCHOR_TUNNEL_SERVICE (default http://traefik:80) is reachable from the cloudflared container, and that the target service is on the same Docker network (web).

Container appears in Anchor UI but no DNS record Check that anchor.name and anchor.access are both set, and look at the Events tab for an error. The most common cause is an API token without enough permissions.

Anchor isn't seeing new containers Make sure the Docker socket is mounted (/var/run/docker.sock:/var/run/docker.sock:ro) and that Anchor has read access to it. For remote hosts, the host needs to be exposing the Docker API on tcp:// and listed in ANCHOR_HOSTS.

Contributing

Issues and pull requests are welcome. For non-trivial changes, please open an issue first to discuss what you'd like to change.

License

MIT

About

Watches your Docker hosts and automatically manages Cloudflare DNS records and Tunnel ingress rules from container labels. Optional Uptime Kuma monitor sync. Built with .NET and React, ships as a single Docker image.

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages