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.
- Features
- How it works
- Prerequisites
- Cloudflare setup
- Install
- Adding a new service
- Optional integrations
- Container labels reference
- Configuration reference
- Building from source
- Development
- Troubleshooting
- Contributing
- License
- 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
externalservices - 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
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.
- 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
You only do this once.
- Go to My Profile → API Tokens → https://dash.cloudflare.com/profile/api-tokens
- Click Create Token → Create Custom Token
- Token name:
anchor - Permissions:
Zone→DNS→EditAccount→Cloudflare Tunnel→Edit
- Zone Resources: Include → Specific zone → (your domain)
- Account Resources: Include → (your account)
- Click Continue to summary → Create Token
- Copy the token now — Cloudflare won't show it again. This is your
CF_API_TOKEN.
- Open the Cloudflare dashboard for your domain
- In the right sidebar, find Account ID and copy it
- This is your
CF_ACCOUNT_ID
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.)
- Go to Zero Trust → https://one.dash.cloudflare.com/
- Networks → Tunnels → Create a tunnel
- Connector: Cloudflared → Next
- Tunnel name:
anchor(or whatever you like) → Save tunnel - 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. - Click Next through the routing screens (Anchor will populate them) and Finish.
- Back on the Tunnels list, click your tunnel. The URL contains the tunnel UUID (
.../tunnels/<UUID>). That's yourCF_TUNNEL_ID.
Pick one of the two options below depending on whether you want automatic HTTPS.
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 web2. 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.example3. 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.com4. Start the stack:
docker compose up -d5. 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.
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 -dInternal 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.
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.
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.
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.
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.
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".
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:3100That'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.
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.
| Label | Required | Description |
|---|---|---|
anchor.name |
yes | Subdomain to create (e.g. grafana → grafana.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) |
| 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 |
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 |
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.
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 runhttps://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.
Issues and pull requests are welcome. For non-trivial changes, please open an issue first to discuss what you'd like to change.