Tutorial: Exposing a Dockerized Web App via NPM Direct DB Config
Tutorial: Exposing a Dockerized Web App via NPM Direct DB Config
Audience: Self-hosters who need to publish a Dockerized service through Nginx Proxy Manager without admin-panel credentials.
Last updated: 2026-07-11
My Dockerized Web App — NPM Proxy Host Setup (Direct DB Method)
Scope: Expose a Dockerized internal web application through Nginx Proxy Manager (NPM) on <docker-host> without using the NPM Admin UI, because NPM admin-panel credentials are not stored in the BRC credential store.
Outcome: http://app.example.org resolves to the analyzer on <docker-host> (<npm-host-zerotier-ip>:<app-port>) via NPM proxy host <proxy-host-id>. HTTPS is pending the manual SSL-certificate step in the NPM UI.
What You Need
| Item | Value / Location |
|---|---|
| Target domain | app.example.org
|
| Backing service | <npm-host-zerotier-ip>:<app-port> (<docker-host> ZeroTier, myapp container)
|
| NPM host | <docker-host> (<npm-host-local-ip> local / <npm-host-zerotier-ip> ZeroTier)
|
| NPM DB container | nginx-proxy-manager-db
|
| NPM app container | nginx-proxy-manager-app
|
| NPM DB credentials | npm / npm (hardcoded in NPM stack)
|
| <docker-host> SSH credentials | <credential-store>/<docker-host>
|
| Tooling on the agent side | sshpass (command -v sshpass to verify)
|
Step 1 — Deploy the Application
The analyzer runs as a Docker Compose stack on <docker-host>:
# On <docker-host> /opt/myapp/docker compose up -d
It exposes host port <app-port> and has a one-minute cron sync that pulls main and restarts the container when the repo changes.
Verify it responds locally:
curl http://127.0.0.1:<app-port>/api/health
# expected: {"status":"ok","authenticated":false}
Step 2 — Set Up the SSH Helper
From the workspace, load <docker-host> credentials from BRC and define a one-line SSH wrapper:
WORKDIR=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
BRC="$WORKDIR/<credential-store>/<docker-host>"
PASS=$(grep 'password:' "$BRC" | awk '{print $NF}')
HOST=$(grep 'zerotier_ip:' "$BRC" | awk '{print $NF}')
<docker-host>_USER=$(grep -m1 'user:' "$BRC" | awk '{print $NF}')
<docker-host>_SSH() {
sshpass -p "$PASS" ssh \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$<docker-host>_USER@$HOST" "$@"
}
If sshpass is missing, install it first (apt-get install -y sshpass or equivalent). Do not treat a missing sshpass as "no SSH access."
Step 3 — Check for Existing Hosts
List any existing proxy host that uses the target domain:
<docker-host>_SSH "docker exec nginx-proxy-manager-db mysql -unpm -pnpm -h127.0.0.1 npm -e \\" SELECT id, domain_names, forward_host, forward_port, enabled FROM proxy_host WHERE domain_names LIKE '%app.example.org%' AND is_deleted=0; \\""
If a row exists but points to the wrong target, update it or delete it before inserting a duplicate.
Step 4 — Back Up the proxy_host Table
Always backup before writing:
<docker-host>_SSH "docker exec nginx-proxy-manager-db mysqldump -unpm -pnpm -h127.0.0.1 npm proxy_host" \ > "/tmp/npm-proxy-host-backup-$(date +%Y%m%d-%H%M%S).sql"
Note: Use
-h127.0.0.1; the default socket connection may reject thenpmuser.
Step 5 — Insert the Proxy Host Row
Insert an HTTP-only row (certificate_id=0, ssl_forced=0). SSL is requested later from the NPM UI.
DOMAIN="app.example.org"
TARGET_IP="<npm-host-zerotier-ip>"
TARGET_PORT="<app-port>"
<docker-host>_SSH "docker exec nginx-proxy-manager-db mysql -unpm -pnpm -h127.0.0.1 npm -e \\"
INSERT INTO proxy_host
(created_on, modified_on, owner_user_id, domain_names, forward_host, forward_port,
access_list_id, certificate_id, ssl_forced, caching_enabled, block_exploits,
advanced_config, meta, allow_websocket_upgrade, http2_support, forward_scheme, enabled, locations,
hsts_enabled, hsts_subdomains, trust_forwarded_proto)
VALUES
(NOW(), NOW(), 1, '[\\\\\"${DOMAIN}\\\\\"]', '${TARGET_IP}', ${TARGET_PORT},
0, 0, 0, 0, 0,
'',
'{\\\\\"nginx_online\\\\\":true,\\\\\"nginx_err\\\\\":null}',
0, 0, 'http', 1, '[]',
0, 0, 0);
\\""
Record the id returned by the next SELECT; for this host it is <proxy-host-id>.
Step 6 — Archive Any Manual Config File
If a previous workaround created a hand-edited .conf file in /data/compose/3/data/nginx/proxy_host/, move it out of nginx's include path so it does not conflict with the DB-generated config. The proxy_host directory is owned by root, so use a privileged throwaway container:
<docker-host>_SSH "docker run --rm \ -v /data/compose/3/data/nginx/proxy_host:/proxy \ alpine sh -c 'test -f /proxy/<manual-config>.conf && mv /proxy/<manual-config>.conf /proxy/<manual-config>.conf.manual-backup-$(date +%Y%m%d-%H%M%S) || true'"
Step 7 — Restart the NPM App Container
Restarting regenerates the nginx configs from the DB:
<docker-host>_SSH "docker restart nginx-proxy-manager-app" sleep 5
Step 8 — Force Config Generation If Needed
Direct DB inserts create the row, but NPM's internal config generator may not write proxy_host/<id>.conf until the host is created or updated through the app. If the config file is missing, force it from inside the app container:
HOST_ID=<proxy-host-id>
<docker-host>_SSH "docker exec nginx-proxy-manager-app sh -c 'cat > /tmp/regen-proxy-host.mjs <<EOF
import proxyHostModel from \\"/app/models/proxy_host.js\\";
import internalNginx from \\"/app/internal/nginx.js\\";
const host = await proxyHostModel.query().where(\\"id\\", ${HOST_ID}).first();
if (!host) {
console.error(\\"proxy_host ${HOST_ID} not found\\");
process.exit(1);
}
await internalNginx.configure(proxyHostModel, \\"proxy_host\\", host);
console.log(\\"Regenerated config for\\", host.domain_names);
EOF
node /tmp/regen-proxy-host.mjs'"
This writes the config file, tests nginx, updates the row's meta, and reloads nginx.
Step 9 — Verify
Check the generated config:
<docker-host>_SSH "ls -la /data/compose/3/data/nginx/proxy_host/<proxy-host-id>.conf" <docker-host>_SSH "cat /data/compose/3/data/nginx/proxy_host/<proxy-host-id>.conf"
Test nginx syntax:
<docker-host>_SSH "docker exec nginx-proxy-manager-app nginx -t"
Test end-to-end HTTP (from <docker-host>, because public DNS may not be live yet):
<docker-host>_SSH "curl -fsS -H 'Host: app.example.org' http://127.0.0.1:80/api/health"
# expected: {"status":"ok","authenticated":false}
Once Cloudflare DNS points app.example.org at the <vps-relay> VPS relay, http://app.example.org/ will work publicly.
Step 10 — Request the SSL Certificate (Manual NPM UI Step)
A DB insert cannot complete an ACME DNS-01 challenge. The final certificate step must be done in the NPM Admin UI:
- Open
http://<npm-host-zerotier-ip>:81(<docker-host> ZeroTier) or your NPM admin URL. - Hosts → Proxy Hosts → click
app.example.org. - Go to the SSL tab.
- SSL Certificate → Request a new SSL Certificate.
- Enable Force SSL, HTTP/2 Support, and HSTS as desired.
- Agree to the Let's Encrypt ToS and request the certificate.
After the certificate issues, NPM updates proxy_host.certificate_id and ssl_forced automatically and regenerates <proxy-host-id>.conf with HTTPS listeners.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Access denied for user 'npm'@'localhost' during backup
|
mysqldump used socket instead of TCP
|
Add -h127.0.0.1
|
Row exists but no <id>.conf after restart
|
Direct DB insert did not trigger config generator | Run the Node force-regeneration script in Step 8 |
nginx: [emerg] duplicate server_name
|
Old manual .conf still in proxy_host/
|
Archive/remove it with a privileged container (Step 6) |
curl returns 404 or no response
|
DNS not pointing at NPM yet, or wrong forward_host / forward_port
|
Verify DNS A record and the proxy_host row
|
| Cert request fails | Let's Encrypt rate limit or Cloudflare API token missing | Check NPM SSL logs; use the unpause link if rate-limited |
References
- Reusable NPM direct-DB skill:
tools/IT-knowledge/skills/npm-direct-db-modification/SKILL.md - Analyzer repo:
<your-app-repo> - NPM Proxy Map in orientation:
<orientation-doc>.md§ Published Sites - Cloudflare/VPS relay notes:
tools/IT-knowledge/networking/NPM_Migration_to_Homelab_VPS_Relay_260401.mw