Skip to content

PatchMon

Github GitHub · Docs


Overview

PatchMon is a self-hosted patch monitoring dashboard. A small agent on each Linux host reports installed packages, pending updates (with security updates flagged), OS version and uptime to a central server. You get one page showing which hosts are behind, instead of SSHing into each one to run apt list --upgradable.

It doesn't replace automatic updates (my Debian hosts run unattended-upgrades for security patches); it's the "did that actually happen everywhere?" view on top.

The stack is four containers: the PatchMon server (web UI + API), Postgres, Redis (job queue) and guacd (for the in-browser remote session feature).

Compose

I installed it from the compose file (not the quick-start script), so it's deployed through git like every other stack.

compose.yaml
name: patchmon

services:

  server:
    image: ghcr.io/patchmon/patchmon-server:2.1.6
    container_name: patchmon-server
    restart: unless-stopped
    env_file: .env
    ports:
      - "3000:3000"
    networks:
      - patchmon-internal
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy
      guacd:
        condition: service_healthy
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  database:
    image: postgres:17.11-alpine@sha256:1bcc18d81671b02906296aad470ec9778a6b71016ce953092024b680bad007b0
    container_name: patchmon-database
    restart: unless-stopped
    env_file: .env
    volumes:
      - /mnt/appdata/patchmon/postgres:/var/lib/postgresql/data
    networks:
      - patchmon-internal
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 3s
      timeout: 5s
      retries: 7
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  redis:
    image: redis:7.4.11-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
    container_name: patchmon-redis
    restart: unless-stopped
    env_file: .env
    # Password in a config file, not on the command line (see below)
    command: ["sh", "-c", "rm -f /tmp/redis.conf && umask 077 && printf 'requirepass %s\\n' \"$$REDIS_PASSWORD\" > /tmp/redis.conf && chown redis:redis /tmp/redis.conf && exec docker-entrypoint.sh redis-server /tmp/redis.conf"]
    volumes:
      - /mnt/appdata/patchmon/redis:/data
    networks:
      - patchmon-internal
    healthcheck:
      # REDISCLI_AUTH instead of `-a <pw>`, so the healthcheck process doesn't expose the password either
      test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"]
      interval: 3s
      timeout: 5s
      retries: 7
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

  guacd:
    image: guacamole/guacd:latest@sha256:2d266a525a72ad55d8ea7cd4ca8342a1b44075759b03660d8684821b3636a922
    container_name: patchmon-guacd
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp:size=64m
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    mem_limit: 512m
    cpus: '1.0'
    networks:
      - patchmon-internal
    healthcheck:
      test: ["CMD-SHELL", "nc -z localhost 4822 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

networks:
  patchmon-internal:
    driver: bridge
.env.example
# One .env shared by all four services (env_file)
POSTGRES_USER=patchmon_user
POSTGRES_DB=patchmon_db
POSTGRES_PASSWORD=<YOUR_PASSWORD>       # openssl rand -hex 32
REDIS_PASSWORD=<YOUR_PASSWORD>          # openssl rand -hex 32
JWT_SECRET=<YOUR_TOKEN>                 # openssl rand -hex 64
CORS_ORIGIN=https://patchmon.<YOUR_LOCAL_DOMAIN>   # the exact URL you open in the browser
# ...plus the server's DB/Redis connection settings from upstream's env.example

Start from upstream's env.example for the full variable list; the names above are the ones the compose file itself uses.

guacd layout changed upstream

The guacd service above is the older layout (its own container on the stack network, image digest-pinned). Since 2.1.4, upstream's compose runs guacd with network_mode: "service:server" (it shares the server's loopback, because the server opens per-session ports on 127.0.0.1) and pins guacamole/guacd:1.6.0. Upgrading the image doesn't change your compose file, so if you use the in-browser RDP feature, copy upstream's current guacd block from the operator guide.

Redis password: config file, not a CLI argument

Upstream's example starts Redis with redis-server --requirepass ${REDIS_PASSWORD}. That puts the password on the command line, where every local user can read it with ps or /proc/<pid>/cmdline, and it shows up in docker inspect's command. The fix is to hand Redis a config file instead:

  1. A shell builtin (printf, so no separate process ever carries the value) writes requirepass <pw> to /tmp/redis.conf inside the container, with umask 077 so it's 0600.
  2. chown redis:redis the file, then exec docker-entrypoint.sh redis-server /tmp/redis.conf. Going through the image's entrypoint keeps its drop from root to the redis user (it only does that when the command starts with redis-server).
  3. The healthcheck uses REDISCLI_AUTH instead of redis-cli -a <pw> for the same reason.
  4. $$REDIS_PASSWORD is doubled so Compose leaves it alone and the container's shell expands it.

Why rm -f comes first: a container restart (as opposed to a recreate) keeps the old /tmp/redis.conf, which is now owned by redis. With the kernel's fs.protected_regular protection (enabled by default on Debian; my hosts run it at 2), root can't overwrite another user's file in a sticky directory like /tmp: can't create /tmp/redis.conf: Permission denied, and the container loops. That showed up only after a host reboot, the first real restart. Unlinking the file is still allowed, so remove it first and write a fresh one.

Check that it worked: ps -eo args | grep requirepass on the host should find nothing, and docker exec patchmon-redis redis-cli ping without auth should answer NOAUTH.

The password is still in the container's environment (like every .env value), which needs Docker or root access to read; that's a different, smaller exposure than a world-readable process list.

Agents

Hosts > Add Host in the UI generates a one-line install command per host (it carries that host's API ID/key). Run it on each Debian host as root; the agent then reports on a schedule. Put the server behind your reverse proxy with HTTPS before you enroll agents, since they send their credentials with every report.

Notes

  • Pin the server version. PatchMon's own operator guide recommends exact version pins in production. Renovate proposes updates; databases (Postgres, Redis) wait for manual review.
  • Logging is capped per container (max-size/max-file), so a chatty server can't fill the disk.
  • Back up /mnt/appdata/patchmon with a pg_dump of the database rather than copying live Postgres files.

Sources & further reading