Jump to content

Tutorial: Exposing a Dockerized Web App via NPM Direct DB Config

From MediawikiCIT


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 the npm user.


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:

  1. Open http://<npm-host-zerotier-ip>:81 (<docker-host> ZeroTier) or your NPM admin URL.
  2. Hosts → Proxy Hosts → click app.example.org.
  3. Go to the SSL tab.
  4. SSL Certificate → Request a new SSL Certificate.
  5. Enable Force SSL, HTTP/2 Support, and HSTS as desired.
  6. 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