npmplus-docker-sync

command module
v1.0.0-beta.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 19 Imported by: 0

README

npmplus-docker-sync

Docker labels → Nginx Proxy Manager & NPMplus. Automatically, safely, in one static binary.

CI CodeQL Go Reference Go Report Card Image

Label a container, and its proxy hosts, redirections, streams and 404 hosts appear in Nginx Proxy Manager. Stop the container, and they disappear again — no clicking, no drift.


[!IMPORTANT] v1.0.0-beta.4 hardens deletion. A stopped container no longer loses its host, a typo in a label can no longer delete one, several instances can share one NPM, and a run that would remove an implausible share of your hosts is refused. See docs/DELETION.md.

v1.0.0-beta.3 changed what host means. It is now the upstream target, as in Redth/npm-docker-sync; the domain is domains. Containers no longer need npm.enable, and certificates are selected automatically. See docs/MIGRATION.md.

Why

Nginx Proxy Manager has a lovely UI and a decent API, but every new container still means the same six clicks. Traefik-style label-driven routing is the better workflow — npmplus-docker-sync brings it to NPM without replacing it.

It watches the Docker event stream, reads a handful of labels and reconciles them against the NPM API. It works with both upstream Nginx Proxy Manager (JWT auth) and NPMplus (httpOnly cookie auth) — the auth mode is detected at login, nothing to configure.

Features

🏷️ Label driven npm.proxy.domains: app.example.com — that is the whole configuration
🧩 All four resource types Proxy hosts, redirection hosts, TCP/UDP streams and 404 hosts
🔐 Automatic certificates Picks the matching certificate, exact before wildcard, and keeps it stable
🎛️ Global defaults NPM_PROXY_* / NPM_DEFAULT_* for every field, overridden per container
🤝 Redth compatible The labels and environment of npm-docker-sync work unchanged
🔢 Many services per container Indexed labels: npm.proxy.1.domains, npm.2.stream.incoming_port
🎯 IP-based upstreams Resolves the container's IP instead of its name — no more DNS-related 502s
🔁 Event driven Reacts to start / stop / die / destroy within seconds
🧠 Idempotent Per-kind fingerprint cache: no API call when nothing changed
🧹 Self cleaning Removes resources whose container is gone — and only those it created
🛡️ Hard to lose data A stopped container is disabled, not deleted; a broken label protects its hosts; a mass deletion is refused
👥 Multi-instance Several Docker hosts can drive one NPM without fighting over resources
📊 Observable /healthz, /readyz, /status (JSON) and /metrics (Prometheus)
🔐 Socket-proxy ready Talks to Docker over TCP, so the raw socket never enters the container
🔑 Dual auth NPM (Bearer JWT) and NPMplus (httpOnly cookie), auto-detected
🧊 Debounced A docker compose up of 20 services triggers one reconcile
🚦 Serialised writes A single worker goroutine — no SQLITE_BUSY from concurrent writes
🛑 Graceful shutdown SIGTERM drains the queue and flushes state before exiting
🪶 Tiny FROM scratch, non-root (1000:1000), ~8 MB image, no runtime deps
🧪 Tested Table-driven unit tests, mocked Docker + NPM APIs, -race in CI

Quick start

git clone https://github.com/VentumPhoenix/npmplus-docker-sync.git
cd npmplus-docker-sync

cp .env.example .env            # NPM_URL, NPM_IDENTITY, ...
mkdir -p secrets && printf '%s' 'your-npm-password' > secrets/npm_password.txt
chmod 600 secrets/npm_password.txt

docker compose up -d            # starts docker-socket-proxy + npmplus-docker-sync
docker compose logs -f npmplus-docker-sync

Then label any container on the same Docker host:

services:
  whoami:
    image: traefik/whoami
    networks: [npm]
    labels:
      npm.proxy.domains: "whoami.example.com"

Within a few seconds the proxy host whoami.example.com → http://172.20.0.5:80 exists in NPM — with the port read from the image's EXPOSE, the matching certificate attached and HTTPS enforced. docker compose down removes it again.

Opt a container out with npm.enable: "false".

One container, many resources

Indexed labels expose several services from a single container — and each index can be a different resource type:

labels:
  # 0: the web UI
  npm.proxy.domains: "app.example.com"
  npm.proxy.port: "8080"

  # 1: the metrics endpoint on another port, behind an access list
  npm.1.proxy.domains: "metrics.app.example.com"
  npm.1.proxy.port: "9090"
  npm.1.proxy.access_list: "Intern"

  # 2: the database, exposed as a TCP stream
  npm.2.stream.incoming_port: "5432"

  # 3: redirect the old domain
  npm.3.redirect.domains: "old-app.example.com"
  npm.3.redirect.forward_domain: "app.example.com"

  # 4: park a domain on a 404 page
  npm.4.404.domains: "parked.example.com"

Labels without an index belong to index 0, so npm.proxy.domains and npm.0.proxy.domains are the same thing. The index may also follow the kind (npm.proxy.1.domains), which is how Redth writes it.

Check before you deploy
npmplus-docker-sync validate              # check the labels of the running containers
docker compose config --format json > stack.json
npmplus-docker-sync validate stack.json   # check a stack before it even starts
npmplus-docker-sync sync                  # exactly one reconcile, then exit

validate never contacts NPM — with a file it does not need Docker either — and exits non-zero when a label set is broken, which makes it a useful step in a pipeline before the stack goes live.

Without compose
docker run -d --name npmplus-docker-sync \
  --network npm \
  -e DOCKER_HOST=tcp://docker-socket-proxy:2375 \
  -e NPM_URL=http://npm:81 \
  -e NPM_IDENTITY=admin@example.com \
  -e NPM_SECRET=changeme \
  -e NPM_NETWORK=npm \
  ghcr.io/ventumphoenix/npmplus-docker-sync:latest
From source

Requires Go 1.26 or newer — that is the minimum of the official Docker SDK, which is this project's only direct dependency.

make build     # static binary ./npmplus-docker-sync
make test      # race-enabled unit tests
make check     # fmt + vet + lint + test

How it works

                    ┌────────────────────────┐
  docker events ───▶│ listener  (filtered)   │   type=container
  (start/die/stop)  │ internal/docker        │   event=start,die,stop,…
                    └───────────┬────────────┘
                                │ Event
                    ┌───────────▼────────────┐
                    │ debouncer              │   quiet period 3s
                    │ internal/syncer        │   hard cap 30s
                    └───────────┬────────────┘
                                │ []Event (one batch per burst)
                    ┌───────────▼────────────┐   ┌────────────────────┐
                    │ worker (single         │──▶│ state cache        │
                    │ goroutine, sequential) │◀──│ RWMutex, per kind: │
                    └───────────┬────────────┘   │ proxy · redirect · │
                                │                │ stream · 404       │
                                │                └────────────────────┘
                    ┌───────────▼────────────────────────────────────┐
                    │ NPM / NPMplus REST API                         │
                    │ /proxy-hosts /redirection-hosts                │
                    │ /streams     /dead-hosts                       │
                    └────────────────────────────────────────────────┘
  1. Reconciliation on startup. Desired state (container labels) is compared against live state (all four NPM collections) and the delta is applied — so a restart after downtime repairs whatever drifted.
  2. Events are filtered server-side: the daemon only ever sends container lifecycle events, which is what a socket proxy can be locked down to.
  3. Debouncing collapses bursts. docker compose up with 20 services produces one reconcile, not 20.
  4. One worker performs every write. NPM stores its config in SQLite, which does not appreciate concurrent writers.
  5. A per-kind fingerprint cache (SHA-256 over the canonical config) short-circuits no-op updates, so a periodic resync costs a handful of GETs.
  6. The certificate list is polled on its own schedule, because a certificate issued in the NPM UI is not a Docker event.
  7. SIGTERM stops event intake, runs a final reconcile with a detached context and exits — nothing is lost when Watchtower or Proxmox restarts the container.

See docs/ARCHITECTURE.md for the details.

Upstream host resolution

NPM needs to reach your container. Using the container name only works when NPM can resolve Docker's internal DNS, which is the most common cause of 502 Bad Gateway after a proxy host is created.

npmplus-docker-sync therefore defaults the upstream to the container's IP address:

  1. an explicit npm.proxy.host / npm.stream.host label, else
  2. with NPM_NETWORK set — the IP in exactly that network. A container that is not attached to it is skipped with a warning naming the networks it is on. Nothing else is tried: an address on a network NPM does not share is unreachable, and so is the container name, so a proxy host pointing there would only produce a silent 502.
  3. with NPM_CONTAINER_NAME set — the networks that container is on, so naming the NPM container is enough to get the resolution right.
  4. without either — the IP in a network this sync container is itself attached to, else the IP of the first network (alphabetically), else the container name as a last resort.

Set RESOLVE_CONTAINER_IP=false to go back to name-based upstreams, or npm.<kind>.resolve_ip=false for a single resource. NPM_NETWORK_STRICT=false restores the old fall-through behaviour if you really do want it.

[!NOTE] Container IPs change when a container is recreated. That is fine: the next event triggers a reconcile and the proxy host is updated within seconds.

Labels

<prefix>.enable=false                     exclude the container
<prefix>.<kind>.<field>                   one resource, index 0
<prefix>.<kind>.<index>.<field>           indexed (Redth's position)
<prefix>.<index>.<kind>.<field>           indexed (our own position)
<prefix>.<field>                          shorthand, <kind> defaults to proxy

<prefix> is npm by default (LABEL_PREFIX) and may be followed by . or -. <kind> is one of proxy, redirect, stream, 404 (alias dead). Inside a field name ., _ and - are interchangeable, so ssl.hsts.subdomains, ssl.hsts_subdomains and ssl-hsts-subdomains are the same field. Booleans accept true/false, 1/0, yes/no, on/off.

Every container carrying at least one label of the namespace is managed; npm.enable: "false" opts out, and NPM_EXPOSED_BY_DEFAULT=false restores the old "opt-in only" behaviour.

[!IMPORTANT] domains is the domain the world asks for, host is the upstream it is served from — the meaning Redth's labels have. For redirection and 404 hosts, which have no upstream, host stays an alias for domains.

The full table of fields, aliases, environment variables and defaults is generated from the code: docs/FIELDS.md. docs/LABELS.md explains the rules; the highlights:

Defaults you can move

Every field has an environment variable that changes its default for all containers, and a label always wins over it:

# on the sync container
NPM_PROXY_SSL_FORCE: "true"        # https everywhere ...
NPM_DEFAULT_CERTIFICATE: "auto"
# ... except here
npm.proxy.ssl.force: "false"

The resolution order per field is: indexed label → label → NPM_<KIND>_<FIELD>NPM_DEFAULT_<FIELD> → built-in default.

Certificates pick themselves

certificate defaults to auto: the tool looks through the certificates that exist in NPM and attaches the one that fits best — exact match before wildcard, ties broken by the longest remaining validity. Expired certificates, deleted ones and NPMplus' client CAs (provider: mtls) are never considered, a wildcard covers exactly one label (RFC 6125), and a certificate that already fits is kept rather than swapped for an equally good one.

npm.proxy.certificate: "auto"                # the default
npm.proxy.certificate: "12"                  # a fixed id
npm.proxy.certificate: "*.home.example.com"  # whatever covers this domain
npm.proxy.certificate: "name:My wildcard"    # by nice name
npm.proxy.certificate: "new"                 # request a Let's Encrypt one
npm.proxy.certificate: "none"                # deliberately plain HTTP

ssl.forced defaults to auto — on as soon as a certificate is attached. New certificates are noticed within CERTIFICATE_POLL_INTERVAL (1 minute), without a restart.

Ports guess themselves

port defaults to auto: a container that exposes exactly one TCP port needs no port label at all. With several ports NPM_PORT_PREFERENCE (80,8080,3000,8000,443) decides, and when none of them matches the resource is skipped with a message naming the ports it found.

Everything NPMplus can do

auth_request (Authelia, Authentik, tinyauth, …), crowdsec_appsec, noindex, x_frame_options, fancyindex, upstream_compression, request_buffering, response_buffering, location_config, HTTP/3, the stream extras and custom location blocks with their nginx modifiers are all labels. Against upstream nginx-proxy-manager the NPMplus-only ones are left out of the request and reported once, instead of failing the write.

npm.proxy.domains: "kuma.home.example.com"
npm.proxy.port: "3001"
npm.proxy.auth_request: "authelia"
npm.proxy.access_list: "Intern"
npm.proxy.noindex: "true"

[!NOTE] Three NPMplus switches are "disable X" in the API. The labels are positive — crowdsec_appsec: "true" means the AppSec component is active — and are negated on the way in. The explicit disable_crowdsec_appsec spelling works too.

[!NOTE] The server clears TLS settings that cannot apply: without a certificate ssl.forced is dropped, without ssl.forced HSTS is dropped, and without HSTS ssl.hsts_subdomains is dropped. npmplus-docker-sync applies the same cascade before comparing, so a half-configured host converges instead of being rewritten on every event.

[!NOTE] A label that names no field produces a warning with a suggestion (did you mean npm.proxy.ssl.forced?). STRICT_LABELS=true skips the resource instead, so a typo cannot quietly leave a host without TLS.

Configuration

Variable Default Description
NPM_URL Required. API base URL, e.g. http://npm:81.
NPM_IDENTITY Required. Admin e-mail (alias: NPM_EMAIL).
NPM_SECRET Required. Admin password (alias: NPM_PASSWORD).
<NAME>_FILE Any variable can be read from a file instead (Docker secrets), e.g. NPM_SECRET_FILE.
NPM_TIMEOUT 30s HTTP timeout per API request.
NPM_INSECURE_SKIP_VERIFY false Accept self-signed NPM certificates.
NPM_FLAVOUR auto API dialect: auto, npmplus or npm (alias NPM_FLAVOR). See below.
DOCKER_HOST unix:///var/run/docker.sock unix://, tcp://, npipe:// or ssh://.
NPM_NETWORK The Docker network upstream IPs are taken from.
NPM_CONTAINER_NAME Name of the NPM container; its networks are used when NPM_NETWORK is unset.
NPM_NETWORK_STRICT true With NPM_NETWORK set, skip containers that are not on it instead of using another network.
RESOLVE_CONTAINER_IP true Use container IPs instead of names as upstreams.
SYNC_KINDS all Resource types to manage: proxy,redirect,stream,404.
LABEL_PREFIX npm Label namespace.
NPM_EXPOSED_BY_DEFAULT true Manage every labelled container; false requires npm.enable=true.
NPM_PORT_PREFERENCE 80,8080,3000,8000,443 Order for picking an exposed port.
NPM_DEFAULT_CERTIFICATE auto Default certificate wish for every host.
NPM_CERTIFICATE_PARTIAL primary When no certificate covers all domains: primary or none.
NPM_CERTIFICATE_AUTO_CREATE false Request a certificate when nothing matches.
CERTIFICATE_POLL_INTERVAL 1m How often to look for new certificates.
NPM_<KIND>_<FIELD> / NPM_DEFAULT_<FIELD> Default for any label field, e.g. NPM_PROXY_WEBSOCKETS.
STRICT_LABELS false Skip a resource that carries an unknown label.
MIGRATE_FROM_REDTH false Take over hosts created by npm-docker-sync.
NPM_ON_STOP disable Stopped container: disable, keep or delete its resources.
NPM_STOP_GRACE 1m Ignore a stopped container for this long (restarts, recreates).
SYNC_INSTANCE_ID Docker daemon id Only resources of this instance are managed (needs INFO=1 on a socket proxy).
DELETE_GUARD 0.5 Refuse a run that deletes more than this share (off disables).
DELETE_GUARD_MIN 3 Deletions needed before the share guard applies.
DEBOUNCE_INTERVAL 3s Quiet period after the last event.
DEBOUNCE_MAX_WAIT 30s Hard cap for a continuous event stream.
RESYNC_INTERVAL 5m Periodic full reconcile (0 disables it).
DELETE_ORPHANS true Delete managed resources whose container disappeared.
ADOPT_EXISTING true Take over a pre-existing resource for a labelled key.
DRY_RUN false Log intended changes without calling the API.
SHUTDOWN_TIMEOUT 30s Budget for the final flush after SIGTERM.
HEALTH_ADDR Expose /healthz and /readyz, e.g. :8080.
LOG_LEVEL info debug, info, warn, error.
LOG_FORMAT text text or json (structured log/slog).
LOG_PAYLOADS false Mirror full API request bodies into the debug log (credentials redacted).

Durations accept Go syntax (3s, 1m30s); a bare number means seconds. Tuning advice and the full reference: docs/CONFIGURATION.md.

NPM or NPMplus?

The two forks share their endpoints but not their request schemas, and both validate with additionalProperties: false — a body built for the wrong one is rejected in full with 400 data must NOT have additional properties. The differences that matter:

Nginx Proxy Manager NPMplus
enabled in a write body rejected¹ rejected
Access lists access_list_id (one) npmplus_access_list_ids + npmplus_access_list_type
Location access lists not applicable mandatory on every location
Stream ports integers strings ("8080-8090" ranges allowed)
HTTP/3, gRPC upstreams supported

¹ accepted by the proxy-host schema, ignored everywhere else; this tool never sends it and uses the /enable and /disable endpoints for all four kinds.

The flavour is detected once at startup — from an existing proxy host, falling back to the version object in GET /api/ — and logged:

{"level":"INFO","msg":"detected npm api flavour","flavour":"npmplus","detected_from":"proxy host schema"}

Pin it with NPM_FLAVOUR=npmplus or NPM_FLAVOUR=npm if the detection ever guesses wrong. NPMplus-only settings (HTTP/3, forward auth, CrowdSec, …) are simply left out of an upstream-NPM request and reported once per resource. Configurations that flavour genuinely cannot express — several access lists on upstream NPM, a Let's Encrypt certificate: new on an NPMplus stream, a port range against upstream NPM — fail with a message naming the feature instead of an opaque 400.

[!TIP] Pin your NPM/NPMplus image to a version tag rather than :latest. The request schemas change between releases, and an unattended upgrade can start rejecting payloads that worked yesterday.

Ownership: what gets deleted

Every resource created by this tool is stamped with managed_by: npmplus-docker-sync in its NPM meta field.

  • Resources without that marker are never deleted — your hand-made entries are safe, even if DELETE_ORPHANS=true.
  • Resources carrying another tool's marker are never touched at all. That includes managed_by: npm-docker-sync, written by Redth/npm-docker-sync: both tools can run against the same NPM instance without deleting each other's work. MIGRATE_FROM_REDTH=true takes those hosts over and re-stamps them, keeping Redth's own bookkeeping in the meta — see docs/MIGRATION.md.
  • A domain that another, foreign host already serves is reported as a conflict (with that host's id and owner) and the own host is not created, instead of letting the API answer domain already in use.
  • Resources carrying another sync instance's id (managed_instance) are never touched, so several Docker hosts can drive one NPM.
  • A container whose labels cannot be parsed protects its resources for that run: a typo must never take a host down.
  • A container that is merely stopped keeps its host (NPM_ON_STOP), with its id, so a restart or an update changes nothing but the enabled flag.
  • A run that would delete an implausible share of the managed resources is refused (DELETE_GUARD), as is any run that saw no containers at all or that finds resources created with a different LABEL_PREFIX.

The full list of what deletes and what protects is in docs/DELETION.md.

  • Resources with the marker are deleted as soon as no labelled container claims their key any more.
  • If a labelled key already exists as an unmanaged resource, it is adopted and updated (set ADOPT_EXISTING=false to skip it instead).
  • The final reconcile on shutdown (SIGTERM) never deletes. When a whole stack goes down with docker compose down, the labelled containers usually stop before this one, so a last run with deletion enabled would wipe every managed resource. Creates and updates are still flushed; deletion resumes on the next start or the next periodic resync.

Identity is the alphabetically first domain name per kind — and the incoming port for streams. The same domain can therefore be a proxy host and a 404 host at once without the two colliding. Change the first domain and you get a new resource, not a rename.

[!CAUTION] Before switching DELETE_ORPHANS on, check the hosts that point at your own infrastructure — the NPM admin UI itself, a dashboard, a status page. A resource this tool created during an earlier experiment keeps its marker for good, so once its labels are gone it is an orphan and will be deleted, even though it is the page you manage NPM with. Give those containers their labels back:

services:
  npmplus:
    labels:
      npm.proxy.domains: "npm.example.com"
      npm.proxy.port: "81"

A container excluded with npm.enable: "false" is not evaluated at all, so its hosts look orphaned regardless of what else the container says.

DRY_RUN=true lists every deletion it would perform — do that first:

[dry-run] would delete orphaned resource key=npm.example.com id=64

Run with DRY_RUN=true once against a production NPM before letting it write.

Security

The Docker socket is root-equivalent access to the host, which is why the default deployment never hands it to this container:

npmplus-docker-sync ──tcp──▶ docker-socket-proxy ──unix, ro──▶ /var/run/docker.sock
   (no socket)                 CONTAINERS=1 EVENTS=1
                               PING=1 VERSION=1 POST=0
  • The proxy allows exactly four read-only API groups — no POST, no exec, no images, no volumes. A compromise of npmplus-docker-sync cannot start, stop or modify anything.
  • The socket-proxy network is internal: true, so the proxy has no route out of the host.
  • The image is FROM scratch and runs as USER 1000:1000 with cap_drop: ALL, read_only: true and no-new-privileges.
  • The NPM password is read from a Docker secret and is redacted in every log line (slog.LogValuer).

Details, threat model and the mount-the-socket fallback: SECURITY.md.

Status and metrics

HEALTH_ADDR also serves /status and /metrics:

curl -s localhost:8080/status | jq '.resources[] | {key, id, enabled, certificate_id}'
curl -s localhost:8080/metrics | grep npmsync_

/status lists every managed resource with its container, id, certificate, enabled state and - when the API rejected it - the last error and when it will be retried. /metrics exposes runs, errors, created/updated/deleted counters, blocked deletions, managed resources per kind, the certificate match classes and whether the Docker event stream is connected.

Health checks

With HEALTH_ADDR=:8080:

Endpoint Meaning
GET /healthz Process is alive (200 ok).
GET /readyz 200 once a reconcile ran with Docker and the NPM API reachable; 503 before the first run and while either is unreachable.

/readyz tracks the dependencies, not the workload. A resource the API rejected — a typo in one label, a feature the flavour does not have — is reported as a counter in the body and in the reconcile result, not as 503: every other host is still in sync and a restart would not fix it.

ok: 12 managed hosts, 1 failed, last sync 2026-09-16T13:26:35Z

The runtime image is FROM scratch — there is no shell, no curl and no wget to probe those endpoints with. The binary therefore carries its own probe:

HEALTH_ADDR=:8080 npmplus-docker-sync healthcheck; echo $?

It requests /readyz on HEALTH_ADDR and exits 0 on 200, 1 otherwise. When HEALTH_ADDR is unset the health endpoint is disabled and the probe exits 0 without checking anything, so it never fails a container that was deliberately configured without it.

The image ships a matching HEALTHCHECK (30 s interval, 20 s start period, 3 retries), so docker ps and docker inspect report health out of the box:

docker inspect --format '{{.State.Health.Status}}' npmplus-docker-sync

Both compose files define the same check explicitly, which is what makes depends_on: { condition: service_healthy } usable in your own stacks.

Troubleshooting

Nothing happens when I start a labelled container

Run with LOG_LEVEL=debug. If no docker event lines appear, the event stream is not reaching the tool — check DOCKER_HOST and, when using the socket proxy, that EVENTS=1 is set. If events arrive but nothing is created, the labels are likely invalid; the parser logs the exact label and reason at warn, including a suggestion for a misspelled field name. The start-up line container overview managed=… opted_out=… without_labels=… says how many containers were classified as what.

`login to http://npm:81 failed`

NPM_URL must point at the API port (81 in the default NPM setup, not 80 or 443). Verify the credentials with:

curl -s -X POST http://npm:81/api/tokens \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@example.com","secret":"changeme"}'

NPM returns {"token":"..."}; NPMplus answers with a Set-Cookie header and possibly an empty body. Both are supported — anything else is a credential or URL problem.

`400 data must NOT have additional properties`

The request carried a field the server's JSON schema does not allow. Since v1.0.0-beta.2 the payloads are built per flavour, so this normally means the flavour was guessed wrong or your NPM/NPMplus version moved. Check which one was detected:

{"msg":"detected npm api flavour","flavour":"npmplus","detected_from":"proxy host schema"}

Pin the right one with NPM_FLAVOUR=npmplus or NPM_FLAVOUR=npm. With LOG_LEVEL=debug the rejected request is logged next to the fields it contained, which names the offending property the API refuses to:

{"msg":"npm api rejected a request","method":"POST","path":"/api/nginx/proxy-hosts",
 "status":400,"request_fields":"advanced_config,domain_names,...","error":"..."}

LOG_PAYLOADS=true adds the full body (credential-looking values redacted). If the schema really did change, please open an issue — the contract test in internal/npm/contract_test.go validates every payload against the vendored upstream schemas and needs refreshing via make schemas.

`502 Bad Gateway` on the new host

Upstreams default to the container's IP, which NPM can only reach if both containers share a network. Put NPM and the target on the same network and set NPM_NETWORK to its name: the IP is then taken from that network only, and a container that is not attached to it is skipped with a warning rather than silently pointed at an unreachable address. With RESOLVE_CONTAINER_IP=false the container name is used instead, which additionally requires NPM to resolve Docker's embedded DNS.

A host is updated on every event / every resync

The desired state and the stored state disagree about a field the server rewrites. The two known cascades — TLS settings without a certificate, and the enabled flag — are handled since v1.0.0-beta.2. For anything else, run with LOG_LEVEL=debug and compare the request_fields of two consecutive updates, then open an issue.

`container is not attached to `

With NPM_NETWORK set, upstream IPs are taken from that network only. Either attach the container to it, or set npm.<kind>.forward_host explicitly. If you want the old fall-through behaviour back, set NPM_NETWORK_STRICT=false — but expect upstreams NPM cannot route to.

Streams or 404 hosts are not created

Check SYNC_KINDS — it limits which collections are managed. A warn line saying "collection is not available on this npm instance" means the API returned 404 for that endpoint; the rest keeps working.

`permission denied` on /var/run/docker.sock

Only relevant for docker-compose.simple.yml: the container user needs the host's docker group. Set DOCKER_GID=$(getent group docker | cut -d: -f3) in your .env. The socket-proxy setup avoids this entirely.

A certificate is requested over and over

That would be a bug — once NPM issues a certificate for a certificate: new resource, the assigned id is adopted on the next reconcile and the fingerprint stays stable. If you see repeated issuance, please open an issue with debug logs (Let's Encrypt rate limits are unforgiving).

The wrong certificate was attached

The reason is in the log:

{"msg":"certificate selected","key":"app.home.example.com","id":12,
 "match":"wildcard","pattern":"*.home.example.com"}

match is the class that won: exact, exact+sans, mixed or wildcard. A certificate that already fits is kept, so a freshly imported one only takes over when it matches in a better class. Pin it with npm.proxy.certificate: "<id>" if the automatic choice is not what you want, and use npm.proxy.certificate: "none" to keep a host on plain HTTP.

A host disappeared after a container was stopped

Since v1.0.0-beta.4 it should not: a stopped container has its host disabled, not deleted (NPM_ON_STOP). If it still happens, check whether NPM_ON_STOP=delete is set, and read docs/DELETION.md — it lists every situation in which something is removed, and every safeguard that stops one.

"refusing to delete: ..." in the log

The delete guard stopped a run that would have removed an implausible share of your hosts. The message says why: a changed LABEL_PREFIX, an empty container list, or simply too many at once. Fix the cause, or - when the deletion really is intended - run once with DELETE_GUARD=off.

A host is skipped with "domain already used by another host"

Another host in NPM — created by hand or by a different tool — already serves one of the domains. NPM allows a domain exactly once per collection, so the own host is not created. The message carries the id and the owner of the conflicting host; remove or rename it, then the next reconcile creates yours.

Project layout

main.go                     wiring, signals, health endpoint
internal/config/            environment parsing + validation
internal/fields/            the field table: labels, aliases, env vars, defaults
internal/certs/             certificate matching and selection
internal/npm/               API client and the four resource models
internal/docker/            event listener, indexed label parser, IP resolution
internal/syncer/            debouncer, per-kind state cache, reconcile worker
test/integration/           the suite that runs against real NPM containers
docs/                       architecture, fields (generated), labels, configuration,
                            deletion rules, migration, compatibility

Contributing

Bug reports, labels you are missing and PRs are welcome — see CONTRIBUTING.md. make check must pass, new behaviour needs a test, and commits follow Conventional Commits.

Roadmap

Shipped in v1.0.0-beta.3: Redth label compatibility, automatic certificate selection, global field defaults, opt-out instead of opt-in, port detection, the NPMplus feature set and access lists by name.

Shipped in v1.0.0-beta.4: the deletion safeguards above, sync instances, /status and /metrics, the validate and sync subcommands, a field diff in dry-run and drift reports, plus an integration suite that runs against real NPM and NPMplus containers.

On the way to 1.0.0:

  • A release candidate that runs in a real homelab for a few weeks
  • Upstream modes: container name, host IP with published ports
  • Health-gated activation (NPM_WAIT_HEALTHY)
  • Runtime schema check against /api/schema
  • Docker Swarm service labels

From 1.0.0 onwards, labels, environment variables and the resource meta follow semantic versioning — see docs/COMPATIBILITY.md.

Acknowledgements

Built on Nginx Proxy Manager by jc21, NPMplus by ZoeyVid and docker-socket-proxy by Tecnativa. Inspired by the label-driven workflow of Traefik and by Redth/npm-docker-sync.

Not affiliated with or endorsed by any of these projects.

License

MIT

Documentation

Overview

Command npmplus-docker-sync keeps Nginx Proxy Manager / NPMplus routing resources in sync with the labels of running Docker containers.

It is a single static binary with no runtime dependencies: point it at a Docker endpoint (ideally a docker-socket-proxy) and an NPM API, label your containers, and proxy hosts, redirections, streams and 404 hosts appear, change and disappear with them.

Directories

Path Synopsis
internal
certs
Package certs picks the TLS certificate of a host.
Package certs picks the TLS certificate of a host.
config
Package config loads and validates the runtime configuration from the process environment.
Package config loads and validates the runtime configuration from the process environment.
docker
Package docker turns Docker container metadata into desired NPM resource state and streams container lifecycle events.
Package docker turns Docker container metadata into desired NPM resource state and streams container lifecycle events.
fields
Package fields is the single source of truth for every configurable property of a managed NPM resource.
Package fields is the single source of truth for every configurable property of a managed NPM resource.
npm
Package npm implements a minimal, dependency-free API client for Nginx Proxy Manager and NPMplus.
Package npm implements a minimal, dependency-free API client for Nginx Proxy Manager and NPMplus.
syncer
Package syncer contains the event debouncer, the thread-safe state cache and the single-threaded reconcile worker.
Package syncer contains the event debouncer, the thread-safe state cache and the single-threaded reconcile worker.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL