PatchMon¶
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.
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
# 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:
- A shell builtin (
printf, so no separate process ever carries the value) writesrequirepass <pw>to/tmp/redis.confinside the container, withumask 077so it's0600. chown redis:redisthe file, thenexec docker-entrypoint.sh redis-server /tmp/redis.conf. Going through the image's entrypoint keeps its drop from root to theredisuser (it only does that when the command starts withredis-server).- The healthcheck uses
REDISCLI_AUTHinstead ofredis-cli -a <pw>for the same reason. $$REDIS_PASSWORDis 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/patchmonwith apg_dumpof the database rather than copying live Postgres files.