π€ What does it do?
You run apps in Docker and use Nginx Proxy Manager (NPM) to make them reachable
under nice domains with HTTPS. Normally, every new app means opening the NPM
UI, clicking through a form, picking a certificate β and remembering to delete
the host when the app is gone.
npmplus-docker-sync does that for you. You describe the domain right next
to the app, and NPM follows automatically.
services:
whoami:
image: traefik/whoami
networks: [npm]
labels:
npm.proxy.domains: "whoami.example.com" # β that's it
A few seconds after docker compose up, NPM has a proxy host
whoami.example.com β pointing at the right container and port, with the
matching certificate attached and HTTPS enforced.
| π© Without | π With npmplus-docker-sync |
- Start the container
- Open the NPM UI
- Add proxy host, type domain
- Look up the container IP and port
- Pick the certificate, tick Force SSL
- Save β and repeat for every app
- Forget to delete it later
|
- Add one label
docker compose up -d
- β
Done
Stop the app β the host is disabled.
Remove the app β the host is cleaned up.
|
If you know Traefik's labels: it's that workflow, but for Nginx Proxy Manager β
you keep NPM and its UI exactly as they are.
β¨ Highlights
- π·οΈ One label is enough β port, certificate and HTTPS are figured out automatically
- π§© All four NPM types β proxy hosts, redirections, TCP/UDP streams and 404 hosts
- π Certificates pick themselves β the best matching one, exact before wildcard
- π― No more
502 Bad Gateway β uses the container's IP on the network NPM can actually reach
- π‘οΈ Hard to break β never touches hosts you made by hand, and refuses suspicious mass deletions
- β‘ Fast and quiet β reacts in seconds, and 20 containers starting at once cause one update
- π€ Drop-in for Redth/npm-docker-sync β its labels work unchanged
- π Secure by default β talks to Docker through a read-only socket proxy, runs as non-root in a ~8 MB
scratch image
- π Observable β health checks, a JSON status page and Prometheus metrics built in
π Quick start
You need: Docker with Compose. Ready-made stacks for NPM and NPMplus live in
deploy/ β from a two-container try-out to a hardened setup
with CrowdSec:
1. Pick a stack
git clone https://github.com/VentumPhoenix/npmplus-docker-sync.git
cd npmplus-docker-sync/deploy/npmplus/2-standard
2. Fill in .env
cp .env.example .env # domain of the admin UI, e-mail, password
3. Start it
docker compose up -d
docker compose logs -f npmplus-docker-sync
4. Label your apps β as in the example above, or start
deploy/example-app. Done! π
[!TIP]
Adding it to an NPM that already has hosts? existing-npm starts with
DRY_RUN=true: the tool only logs what it would change. Check the log,
then set it to false.
Without Compose (docker run)
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
For a quick test without a socket proxy, see
deploy/npm/1-simple.
Build from source
Requires Go 1.26 or newer.
make build # static binary ./npmplus-docker-sync
make test # race-enabled unit tests
make check # fmt + vet + lint + test
π Examples
Copy what you need into the labels: of your container.
A web app with HTTPS
npm.proxy.domains: "app.example.com"
β¦on a specific port, with several domains
npm.proxy.domains: "app.example.com, www.app.example.com"
npm.proxy.port: "8080"
β¦behind a login (Authelia, Authentik, β¦) and an access list (login: NPMplus only)
npm.proxy.domains: "kuma.example.com"
npm.proxy.auth_request: "authelia"
npm.proxy.access_list: "Intern"
Redirect an old domain
npm.redirect.domains: "old-app.example.com"
npm.redirect.forward_domain: "app.example.com"
Expose a database as a TCP stream
npm.stream.incoming_port: "5432"
Show a 404 page for a parked domain
npm.404.domains: "parked.example.com"
Several things from one container β just number them:
npm.proxy.domains: "app.example.com" # 0: the web UI
npm.1.proxy.domains: "metrics.example.com" # 1: a second port
npm.1.proxy.port: "9090"
npm.2.stream.incoming_port: "5432" # 2: the database
Leave a container alone
npm.enable: "false"
π Every label β with what it does, its default and aliases β is listed in
docs/FIELDS.md. The rules behind them are in
docs/LABELS.md.
Label syntax in detail
npm.<kind>.<field> one resource
npm.<index>.<kind>.<field> several resources (npm.<kind>.<index>.<field> works too)
npm.<field> shorthand for npm.proxy.<field>
npm.enable=false ignore this container
<kind> is proxy, redirect, stream or 404.
domains is the domain people visit; host is the upstream the app is
served from (for redirections and 404 hosts, host means domains).
., _ and - are interchangeable in field names: ssl.hsts.subdomains
= ssl_hsts_subdomains.
- Booleans accept
true/false, 1/0, yes/no, on/off.
- The prefix
npm can be changed with LABEL_PREFIX.
- A typo gets a warning with a suggestion (
did you mean npm.proxy.ssl.forced?).
Automatic certificates
By default (certificate: auto) the tool picks the certificate in NPM that fits
best: exact match before wildcard, then the one valid the longest. Expired
certificates are ignored, and a certificate that already fits is never swapped
for an equally good one. New certificates from the NPM UI are noticed within a
minute.
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 name
npm.proxy.certificate: "new" # request a Let's Encrypt one
npm.proxy.certificate: "none" # plain HTTP
HTTPS is forced automatically as soon as a certificate is attached.
Automatic ports
If a container exposes exactly one port, you don't need a port label. With
several ports, NPM_PORT_PREFERENCE (80,8080,3000,8000,443) decides. If none
of them fits, the host is skipped and the log names the ports it found.
Defaults for all containers
Every label has a matching environment variable on the sync container. A label
on a container always wins:
# on npmplus-docker-sync: HTTPS everywhere ...
NPM_PROXY_SSL_FORCE: "true"
# ... except on this container
npm.proxy.ssl.force: "false"
Order: indexed label β label β NPM_<KIND>_<FIELD> β NPM_DEFAULT_<FIELD> β
built-in default.
NPMplus extras (groups, CrowdSec, HTTP/3, β¦)
Everything NPMplus can do is a label: auth_request, crowdsec_appsec,
noindex, x_frame_options, fancyindex, upstream_compression,
request_buffering, response_buffering, location_config, HTTP/3, custom
locations β and group, which sorts hosts into the folders of the NPMplus UI:
npm.proxy.group: "Production"
Against the original Nginx Proxy Manager, NPMplus-only settings are simply
left out and reported once β nothing fails.
Check labels before deploying
npmplus-docker-sync validate # check the labels of running containers
docker compose config --format json > stack.json
npmplus-docker-sync validate stack.json # check a stack before it starts
npmplus-docker-sync sync # one sync, then exit
validate never contacts NPM and exits non-zero on broken labels β handy as a
CI step.
π‘οΈ Is it safe?
Short answer: it is built to never destroy something you care about.
| Situation |
What happens |
| You created a host by hand in NPM |
Never deleted. Only hosts the tool created itself are ever removed. |
| Another tool (e.g. Redth's) created a host |
Never touched. |
| A container is stopped |
Its host is disabled, not deleted β a restart just turns it back on. |
| A container is restarted or updated |
Nothing changes (1 minute grace period). |
| A label contains a typo |
That container's hosts are protected, not deleted. |
| A run would delete many hosts at once |
Refused. (DELETE_GUARD, default: more than half) |
| Docker reports no containers at all |
Nothing is deleted. |
| The domain is already used by another host |
Reported as a conflict, nothing is overwritten. |
| Several Docker hosts share one NPM |
Each instance only manages its own hosts. |
| You update NPM / NPMplus |
Your hosts stay yours β see the note below. |
How it recognizes its own hosts: each one gets a comment line at the top of
its Advanced config in NPM β # npmplus-docker-sync: managed from Docker labels, do not edit or remove this line | β¦. nginx ignores it, this tool
relies on it: leave it there. A host without it counts as hand-made.
[!IMPORTANT]
Update this tool before NPMplus. The next NPMplus release clears the
place where versions up to v1.0.0-beta.9 recorded their hosts; v1.0.0-beta.10
adds the line above to every host on its first run.
Details
[!CAUTION]
Before your first real run: if this tool ever created a host for
something you still need β like the NPM admin UI itself β make sure that
container still has its npm.* labels. Run once with DRY_RUN=true and
check the log for would delete lines.
Docker access is read-only. The recommended setup goes through a
docker-socket-proxy that
only allows reading containers and events, so even a compromised sync
container cannot start, stop or change anything. The container runs as
non-root from an empty scratch image with a read-only filesystem, and the NPM
password never appears in logs.
More: every deletion rule Β· threat model
βοΈ Configuration
Most setups only need these:
| Variable |
Default |
What it does |
NPM_URL |
β |
Required. Address of the NPM API, e.g. http://npm:81 |
NPM_IDENTITY |
β |
Required. NPM admin e-mail |
NPM_SECRET / NPM_SECRET_FILE |
β |
Required. NPM admin password (or a file containing it) |
NPM_NETWORK |
β |
The Docker network NPM and your apps share |
DOCKER_HOST |
unix:///var/run/docker.sock |
How to reach Docker (tcp://docker-socket-proxy:2375 in the recommended setup) |
DRY_RUN |
false |
Only log what would change |
NPM_ON_STOP |
disable |
What a stopped container does to its hosts: disable, keep or delete |
LOG_LEVEL |
info |
debug, info, warn, error |
HEALTH_ADDR |
β |
Turn on /healthz, /readyz, /status, /metrics, e.g. :8080 |
Any variable can be read from a file by adding _FILE (Docker secrets). All
variables, with aliases and tips: docs/CONFIGURATION.md.
NPM or NPMplus? Both work, detected automatically. The tool reads which
fields the server accepts from the server itself, so a new NPM or NPMplus
release rarely needs an update here. NPMplus-only settings (HTTP/3, CrowdSec,
groups, forward auth, β¦) are simply left out against NPM and reported once.
[!TIP]
Pin your NPM/NPMplus image to a version tag rather than :latest.
π How it works
flowchart LR
D[π³ Docker<br/>container events] --> L[Read labels]
L --> W[Wait for the burst<br/>to settle Β· 3s]
W --> C{Compare with NPM}
C -->|new| A[Create host]
C -->|changed| U[Update host]
C -->|unchanged| S[Do nothing]
C -->|container gone| R[Remove own host<br/>with safety checks]
- On startup it compares all labelled containers with everything in NPM
and fixes the differences β so downtime never leaves things out of sync.
- Then it listens for containers starting and stopping, and syncs again
within seconds.
- Every 5 minutes it does a full check anyway, and every minute it looks
for new certificates.
- Unchanged hosts cost nothing β no API write happens unless something
actually changed.
Why the container IP? NPM must reach your app, and container names only
work if NPM resolves Docker's internal DNS β the usual cause of
502 Bad Gateway. So the upstream is the container's IP in the network NPM
shares (NPM_NETWORK). How it is chosen Β·
Architecture
π Monitoring
With HEALTH_ADDR=:8080:
| Endpoint |
Shows |
/healthz |
The process is alive |
/readyz |
Docker and NPM are reachable and a sync has run |
/status |
Every managed host as JSON, including errors and retries |
/metrics |
Prometheus metrics (runs, errors, changes, blocked deletions, β¦) |
The image has a built-in Docker HEALTHCHECK, so docker ps shows its health
and other services can use depends_on: { condition: service_healthy }.
π©Ί Troubleshooting
Nothing happens when I start a labelled container
Run with LOG_LEVEL=debug. No docker event lines? Then events aren't arriving
β check DOCKER_HOST and that the socket proxy has EVENTS=1. Events arrive
but nothing is created? Then a label is probably wrong β the log names it, often
with a suggestion. The startup line container overview managed=β¦ opted_out=β¦
shows how each container was classified.
login to http://npm:81 failed
NPM_URL must point to the admin/API port (81 by default β not 80 or 443).
Test your credentials:
curl -s -X POST http://npm:81/api/tokens \
-H 'Content-Type: application/json' \
-d '{"identity":"admin@example.com","secret":"changeme"}'
502 Bad Gateway, or container is not attached to <network>
NPM and your app must share a Docker network. Attach both to it and set
NPM_NETWORK to its name β or give the host an explicit npm.proxy.host.
400 data must NOT have additional properties
The server refused a field. The tool re-reads what the server accepts right
away, so the next sync usually succeeds. If it keeps failing, run with
LOG_LEVEL=debug (names the fields) and please open an issue with that output.
The wrong certificate was attached
The log says why ("msg":"certificate selected", "match":"wildcard", β¦). Pin
the one you want with npm.proxy.certificate: "<id>", or use "none" for plain
HTTP.
refusing to delete: β¦ in the log
A safety check stopped a run that would have removed too many hosts. The
message says why (changed LABEL_PREFIX, no containers seen, or simply too
many at once). Fix the cause β or, if the deletion is really intended, run once
with DELETE_GUARD=off.
domain already used by another host
Another host in NPM (made by hand or by another tool) already serves that
domain. The message names its id β remove or rename it, and the next sync
creates yours.
A host disappeared after stopping a container
By default stopped containers only disable their host. Check whether
NPM_ON_STOP=delete is set, and see docs/DELETION.md.
Streams or 404 hosts are not created
Check SYNC_KINDS. A warning "collection is not available on this npm
instance" means your NPM doesn't offer that type.
permission denied on /var/run/docker.sock
Only with the simple stacks, which mount the socket directly: set
DOCKER_GID=$(getent group docker | cut -d: -f3) in .env. The socket-proxy
stacks avoid this entirely.
π Coming from Redth/npm-docker-sync?
Your labels and environment variables work unchanged. Both tools can even run
against the same NPM without deleting each other's hosts. To take over the
hosts Redth's tool created, set MIGRATE_FROM_REDTH=true β see
docs/MIGRATION.md.
π Documentation
| Document |
What's in it |
| Labels |
syntax, defaults, certificates, groups, locations, streams |
| All label fields |
every label with aliases, defaults and env variables (generated) |
| Configuration |
every environment variable, health, metrics, commands |
| Deletion |
when something is deleted β and what prevents it |
| Upgrading |
per release, and coming from Redth/npm-docker-sync |
| Compatibility |
supported NPM/NPMplus versions, the 1.0 promise |
| Architecture |
how it works inside |
| Security |
threat model, reporting a vulnerability |
πΊοΈ Roadmap
1.0.0 β
β labels, variables and the ownership marker are stable
(what that means).
Next β in 1.x releases, without breaking changes:
- The next NPMplus release in the test matrix (its development branch is tested every night already)
- Upstream modes: container name, or host IP with published ports
- Waiting for a container to be healthy before publishing it (
NPM_WAIT_HEALTHY)
- Docker Swarm service labels
- NPMplus mTLS client certificates and certificate requests for streams
See the changelog for what changed, and
COMPATIBILITY.md for what stays stable.
π€ Contributing
Bug reports, missing labels and pull requests are welcome β see
CONTRIBUTING.md. If this tool saves you some clicks, a β
helps others find it.
π Acknowledgements
Built on Nginx Proxy Manager by jc21,
NPMplus by ZoeyVid and
docker-socket-proxy by
Tecnativa. Inspired by Traefik's label-driven workflow and by
Redth/npm-docker-sync.
Not affiliated with or endorsed by any of these projects.
π License
MIT