Skip to content

Nginx Proxy Manager swarm template

replaced

i moved to traefik, one name at a time, so the reverse proxy's routes live in git. npm is stopped, and will be retired once traefik has run on its own for a while.

Description

This template runs NPM, my reverse proxy.

State Considerations for SWARM

  • This container has a database. The data, the certificates and the database are named binds on cephfs, see stack conventions.
  • I restrict to 1 instance of each container to avoid database corruption from having two instances.
  • Both services read their passwords from swarm secrets through entrypoint wrappers, see secrets.
  • Leave hostname as db (name resolution works fine using this method).
  • If you place the database in a different stack / want to use an existing database then both stacks need to share a network.
  • A third service, db-dump, dumps the database to /mnt/docker-cephFS/npm_dumps at start and at :50 every hour, so every cephFS backup holds a consistent copy. It runs the database's image with the same script as wordpress's hourly dump. The folder has to exist before the first deploy (sudo mkdir -m 700 /mnt/docker-cephFS/npm_dumps).
  • The tables are Aria, which has no consistent read view, so the dump uses --lock-tables: writers wait for it, readers carry on. The database is 0.5 MB and the dump takes under a second, and only npm's admin side uses the database, while nginx proxies from the config files npm generates.
  • If mysqldump --routines fails with Cannot load from mysql.proc (error 1728), the system tables were made by an older MariaDB and never upgraded. mariadb-upgrade inside the db container fixes that; take a dump first.
  • The image creates two anonymous accounts when it starts on an empty data folder, ''@'localhost' and one for that first container's hostname, and never removes them. Nothing uses them, and mysql -u npm inside the db container is matched to the anonymous one instead of npm, so I dropped both, with the grants they leave on test databases. As root in the db container: SELECT Host FROM mysql.user WHERE User = '' gives the hostname, then DROP USER ''@'localhost', ''@'<hostname>'; DELETE FROM mysql.db WHERE User = ''; FLUSH PRIVILEGES;.

Network Considerations

This publishes 80, 443 and 81 (admin) as 180, 1443 and 181, so the admin UI is at swarmIP:181.

Placement Considerations

cephfs allows the replica to run on any node. I hard set 1 replica (even though that's default) to avoid corruption of the database. Not sure it will corrupt, this is just my own caution.

compose.yml, 119 lines, 5 notes

each in the code opens a note on that line. download compose.yml

services:

  app:
    image: 'jc21/nginx-proxy-manager:2.15.1'
    restart: always
    entrypoint:  # (1)!
      - /bin/sh
      - -c
      - >
        set -e;
        DB_MYSQL_PASSWORD="$$(cat /run/secrets/npm_db_password_v2)";
        export DB_MYSQL_PASSWORD;
        exec /init
    ports:  # (2)!
      - '180:80'
      - '1443:443'
      - '181:81'
    environment:
      TZ: America/Los_Angeles
      DB_MYSQL_HOST: "db"
      DB_MYSQL_PORT: 3306
      DB_MYSQL_USER: "npm"
      DB_MYSQL_NAME: "npm"
      DISABLE_IPV6: 'true'
    volumes:
      - data:/data
      - le:/etc/letsencrypt
    depends_on:
      - db
    secrets:
      - npm_db_password_v2
    deploy:
      mode: replicated
      replicas: 1

  db:
    image: 'jc21/mariadb-aria:10.11.5'
    restart: always
    entrypoint:  # (3)!
      - /bin/sh
      - -c
      - >
        set -e;
        MYSQL_ROOT_PASSWORD="$$(cat /run/secrets/npm_mysql_root_password_v2)";
        export MYSQL_ROOT_PASSWORD;
        MYSQL_PASSWORD="$$(cat /run/secrets/npm_db_password_v2)";
        export MYSQL_PASSWORD;
        exec /scripts/run.sh
    environment:
      TZ: America/Los_Angeles
      MYSQL_DATABASE: 'npm'
      MYSQL_USER: 'npm'
    volumes:
      - db:/var/lib/mysql
    secrets:
      - npm_db_password_v2
      - npm_mysql_root_password_v2
    deploy:
      mode: replicated
      replicas: 1
      endpoint_mode: dnsrr

  db-dump:  # (4)!
    image: 'jc21/mariadb-aria:10.11.5'
    entrypoint: ["/bin/sh", "/usr/local/bin/db-dump"]
    environment:
      TZ: America/Los_Angeles
      DUMP_HOST: db
      DUMP_DB: npm
      DUMP_PASSWORD_FILE: /run/secrets/npm_mysql_root_password_v2
      DUMP_OPTS: --lock-tables --routines
    configs:
      - source: db_dump
        target: /usr/local/bin/db-dump
        mode: 0555
    secrets:
      - npm_mysql_root_password_v2
    volumes:
      - dumps:/dumps
    deploy:
      mode: replicated
      replicas: 1

secrets:
  npm_db_password_v2:
    external: true
  npm_mysql_root_password_v2:
    external: true

configs:
  db_dump:
    file: ./db-dump.sh
    name: npm_db_dump_v1

volumes:  # (5)!
  db:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/npm_db"
      o: bind
  data:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/npm_data"
      o: bind
  le:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/npm_le"
      o: bind
  dumps:
    driver: local
    driver_opts:
      type: none
      device: "/mnt/docker-cephFS/npm_dumps"
      o: bind
  1. a shell reads the swarm secret into DB_MYSQL_PASSWORD, then execs /init, the image's s6 entrypoint. a missing secret makes the container exit before npm starts.
  2. the router forwards the WAN's 80 and 443 to 180 and 1443 on the nodes, which keeps the nodes' own 80 and 443 free. 181 is the admin UI.
  3. a shell reads the root password and the npm user's password from swarm secrets, then execs /scripts/run.sh, the mariadb-aria entrypoint. the user's password is npm_db_password_v2, the secret the app reads, so a new database gets the password npm logs in with.
  4. the hourly dump: the db image, running the same db-dump.sh as wordpress's instead of mariadb. --lock-tables, because npm's Aria tables have no consistent read view.
  5. named binds on the cephfs mount. npm_db, npm_data and npm_le must exist before the first deploy, or the task refuses to start.
db-dump.sh: the dump sidecar's script, the same as wordpress's, 37 lines

download db-dump.sh

#!/bin/sh

set -u

: "${DUMP_HOST:?}" "${DUMP_DB:?}" "${DUMP_PASSWORD_FILE:?}"
OPTS=${DUMP_OPTS:---single-transaction}
MINUTE=${DUMP_MINUTE:-50}
OUT=/dumps/$DUMP_DB.sql
RETRY=300

log() { echo "db-dump: $(date '+%F %T') $*"; }

dump() {
  tmp="$OUT.tmp"
  start=$(date +%s)
  if MYSQL_PWD="$(cat "$DUMP_PASSWORD_FILE")" mysqldump -h "$DUMP_HOST" -u root $OPTS \
       --hex-blob --order-by-primary --no-tablespaces \
       --databases "$DUMP_DB" > "$tmp" \
     && tail -n 1 "$tmp" | grep -q '^-- Dump completed' \
     && mv -f "$tmp" "$OUT"; then
    log "dumped $DUMP_DB: $(wc -c < "$OUT") bytes in $(( $(date +%s) - start ))s"
    return 0
  fi
  rm -f "$tmp"
  log "FAILED to dump $DUMP_DB; the previous dump is kept, retrying in $(( RETRY / 60 )) minutes"
  return 1
}

while :; do
  if dump; then
    wait=$(( (MINUTE * 60 - $(date +%s) % 3600 + 3600) % 3600 ))
    [ "$wait" -eq 0 ] && wait=3600
  else
    wait=$RETRY
  fi
  sleep "$wait"
done

This page started as a gist: the original, with its comments