Skip to main content
PathMon

Chapter 1 of 15

Installing PatchMon Server on Docker

Updated Read the full guide

Overview

PatchMon runs as a single container backed by three supporting services. The PatchMon server binary serves both the API and the embedded React frontend. There is no separate frontend container.

Service Image Purpose
server ghcr.io/patchmon/patchmon-server PatchMon application (API + frontend + migrations)
database postgres:17-alpine Primary data store
redis redis:7-alpine Background job queues (asynq)
guacd guacamole/guacd:1.6.0 RDP gateway (required for in-browser RDP)

The services communicate over an isolated internal Docker network (patchmon-internal). guacd does not join that network: it shares the server container's network namespace, because the server hands it a per-session RDP port on 127.0.0.1. Only the server port is exposed to the host.


Prerequisites

  • Docker Engine 24+ and Docker Compose v2
  • A reverse proxy with a valid TLS certificate (Nginx Proxy Manager, Traefik, Caddy, or similar), strongly recommended for any non-localhost deployment
  • Minimum 1 GB RAM, 2 GB recommended

Quick Start

1. Run the setup script

The fastest way to get started is the official setup script. It downloads the compose file, generates secrets, and creates your .env in one step:

mkdir patchmon && cd patchmon
bash -c "$(curl -fsSL https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/docker/setup-env.sh)"

If a .env already exists, the script keeps it and its secrets. It generates secrets only for keys that are missing or empty, and runs on Linux and macOS.

Once it completes, skip to step 3.

2. Manual setup (alternative)

If you prefer to set things up yourself:

mkdir patchmon && cd patchmon

# Download the compose file and example env
curl -fsSL -o docker-compose.yml https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/docker/docker-compose.yml
curl -fsSL -o env.example https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/docker/env.example

# Create your .env and generate the three required secrets
cp env.example .env
sed -i "s/^POSTGRES_PASSWORD=$/POSTGRES_PASSWORD=$(openssl rand -hex 32)/" .env
sed -i "s/^REDIS_PASSWORD=$/REDIS_PASSWORD=$(openssl rand -hex 32)/" .env
sed -i "s/^JWT_SECRET=$/JWT_SECRET=$(openssl rand -hex 64)/" .env

3. Configure your access URL

Open .env and set CORS_ORIGIN to the full URL that PatchMon will be reachable at in a browser. This is the only URL-related env var the server reads from .env:

CORS_ORIGIN=https://patchmon.example.com

For a local test without a reverse proxy:

CORS_ORIGIN=http://localhost:3000

If users reach PatchMon on more than one URL (for example an external domain and an internal LAN address), comma-separate the values with no spaces:

CORS_ORIGIN=https://patchmon.example.com,https://patchmon.internal.lan

After first login, go to Settings in the PatchMon UI to configure the server URL that agents use to connect back (protocol, host, port). These are stored in the database, not in .env. The Settings page is also where you configure update intervals, auto-update behaviour, and other server-level options.

Tip: Do not edit the docker-compose.yml file to add env vars. The compose file uses env_file: .env to pass your entire .env into each container. All configuration lives in .env.

For the full list of available environment variables (rate limiting, logging, OIDC SSO, TOTP, database pool tuning, session timeouts, and more), see the PatchMon Environment Variables Reference.

4. Start PatchMon

docker compose up -d

Docker will pull the images, wait for each service to pass its health check, and start the server. The server automatically runs database migrations on startup.

Once all containers are healthy, open the URL you configured in your browser and complete the first-time setup to create your admin account.

You can check the startup logs at any time with:

docker compose logs -f server

Upgrading from 2.0.2 or earlier? ENABLE_LOGGING used to default to false, and with it off the server wrote no application logs at all, which made every "check the logs" step in this guide useless. From 2.0.3 it defaults to true. If you explicitly set ENABLE_LOGGING=false in your .env, or turned logging off in Settings > Environment, that choice is preserved and you will still see no logs.


Container Image

The PatchMon server image is published at:

ghcr.io/patchmon/patchmon-server

Available Tags

Tag Description
latest Latest stable release
x.y.z Exact version pin (e.g. 1.5.0)
x.y Latest patch in a minor series (e.g. 1.5)
x Latest minor and patch in a major series (e.g. 1)
edge Latest development build from the main branch. Unstable, for testing only.

Compose File Reference

This is the production docker-compose.yml used by PatchMon. You do not need to edit it; all configuration is controlled via your .env file.

name: patchmon

services:

  server:
    image: ghcr.io/patchmon/patchmon-server:latest
    restart: unless-stopped
    env_file: .env
    environment:
      GUACD_ADDRESS: "127.0.0.1:4822"
    ports:
      - "${PORT:-3000}:${PORT:-3000}"
    networks:
      - patchmon-internal
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy

  database:
    image: postgres:17-alpine
    restart: unless-stopped
    env_file: .env
    volumes:
      - postgres_data:/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

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    env_file: .env
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks:
      - patchmon-internal
    healthcheck:
      test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 3s
      timeout: 5s
      retries: 7

  guacd:
    image: guacamole/guacd:1.6.0
    restart: unless-stopped
    network_mode: "service:server"
    depends_on:
      server:
        condition: service_started
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        /opt/guacamole/entrypoint.sh -b 127.0.0.1 & pid=$$!
        trap 'kill $$pid; wait $$pid; exit 0' TERM INT
        while kill -0 $$pid 2>/dev/null && ls /sys/class/net | grep -q '^eth'; do sleep 5 & wait $$!; done
        kill $$pid 2>/dev/null; exit 1
    read_only: true
    tmpfs:
      - /tmp:size=64m
      - /home/guacd:size=16m,uid=1000,gid=1000
    healthcheck:
      test: ["CMD-SHELL", "nc -z 127.0.0.1 4822 && ls /sys/class/net | grep -q '^eth' || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes:
  postgres_data:
  redis_data:

networks:
  patchmon-internal:
    driver: bridge

Volumes

Volume Purpose
postgres_data PostgreSQL data directory
redis_data Redis persistence

Agent binaries and SCAP compliance content are embedded directly in the patchmon-server image. No additional volumes are required for them.

You can bind either volume to a host path by editing the compose file:

volumes:
  postgres_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /opt/patchmon/postgres

Note: The server container runs as a non-root user. If you bind volumes to host paths, ensure that user has read/write access to those directories.


Updating PatchMon

By default the compose file uses the latest tag. To update to the newest release:

docker compose pull
docker compose up -d

This pulls the latest image, recreates the server container, and runs any new database migrations automatically on startup. Your data is preserved in the named volumes.

Pinning to a specific version

If you prefer to control when you update, pin the image tag in your docker-compose.yml:

services:
  server:
    image: ghcr.io/patchmon/patchmon-server:1.5.0

Then pull and restart when you are ready to upgrade:

docker compose pull
docker compose up -d

Check the GitHub releases page for version-specific changes and migration notes before upgrading.

Upgrading to 2.1.4: guacd compose change for in-browser RDP

Up to 2.1.3, the guacd service in the shipped docker-compose.yml could not open any RDP session. It ran on its own network address, so it could not reach the per-session port the server opens on 127.0.0.1, and its read-only filesystem left FreeRDP no writable home directory. Every connection failed straight after Connect, typically with Server refused connection or Security negotiation failed.

docker compose pull does not change your compose file, so if you use in-browser RDP, make these changes to your existing docker-compose.yml:

  1. In the server service, delete the guacd: entry (and its condition: service_healthy line) under depends_on, and add:

        environment:
          GUACD_ADDRESS: "127.0.0.1:4822"
    

    If the service already has an environment: block, add the GUACD_ADDRESS line to it.

  2. In the guacd service, delete the networks: block (the - patchmon-internal line under it), then add network_mode, depends_on, entrypoint, command and the second tmpfs entry, and change the healthcheck, so it reads:

      guacd:
        image: guacamole/guacd:1.6.0
        restart: unless-stopped
        network_mode: "service:server"
        depends_on:
          server:
            condition: service_started
        entrypoint: ["/bin/sh", "-c"]
        command:
          - |
            /opt/guacamole/entrypoint.sh -b 127.0.0.1 & pid=$$!
            trap 'kill $$pid; wait $$pid; exit 0' TERM INT
            while kill -0 $$pid 2>/dev/null && ls /sys/class/net | grep -q '^eth'; do sleep 5 & wait $$!; done
            kill $$pid 2>/dev/null; exit 1
        read_only: true
        tmpfs:
          - /tmp:size=64m
          - /home/guacd:size=16m,uid=1000,gid=1000
        healthcheck:
          test: ["CMD-SHELL", "nc -z 127.0.0.1 4822 && ls /sys/class/net | grep -q '^eth' || exit 1"]
    

    Keep the rest of the service (security_opt, cap_drop, limits, the healthcheck timings, logging) as it is. The -b 127.0.0.1 matters: without it guacd also listens on the server container's network address, where any other container on that network could use it to open connections to the server's loopback ports. The command keeps guacd attached to the server container: if the server container is restarted on its own, or crashes and is restarted, guacd exits within a few seconds and Docker restarts it in the server's new network namespace. Docker cannot do that for a server container that was recreated on its own (for example docker compose up -d --force-recreate server): run docker compose up -d afterwards and Compose recreates guacd too. A normal docker compose pull && docker compose up -d handles both containers itself.

  3. In .env, delete the GUACD_ADDRESS=guacd:4822 line, or change it to GUACD_ADDRESS=127.0.0.1:4822. The compose environment: entry overrides it either way, but leaving the old value in place is confusing.

  4. Recreate the stack so both containers pick up the change:

    docker compose pull
    docker compose up -d --force-recreate server guacd
    

Alternatively, download the current docker/docker-compose.yml from the repository and re-apply any local edits you had made.

To check it worked, run docker compose exec server sh -c 'nc -z 127.0.0.1 4822 && echo guacd reachable', then open an RDP session from a Windows host's RDP tab.

Migrating from 1.4.2 to 2.0.0

PatchMon 2.0.0 replaced the separate backend and frontend containers with a single server container, so a 1.4.2 stack cannot be upgraded with docker compose pull alone. The migration script tools/migrate1-4-2_to_2-0-0.sh in the PatchMon repository converts your compose directory to the new layout.

What the script does

  1. Checks that docker-compose.yml exists, that Docker and the Compose plugin are installed, and that the compose file still uses the 1.4.2 layout. It stops without changing anything if the stack is already migrated.
  2. Downloads the current docker/docker-compose.yml and docker/env.example from the main branch and checks that they look valid. Because these use the latest image tag, you end up on the newest 2.x release rather than exactly 2.0.0. Set UPSTREAM to a different raw base URL if you need to pull them from somewhere else.
  3. Copies your docker-compose.yml and .env (if you have one) into a backup-1-4-2-<timestamp>/ folder next to them.
  4. Runs docker compose down, then installs the new compose file and .env template.
  5. Fills in each variable the new template contains with its value from your old .env, falls back to values written directly in the old compose file where .env had none (for example JWT_SECRET, CORS_ORIGIN, the Postgres settings, and the Redis password from either the backend environment or --requirepass), and merges CORS_ORIGIN and CORS_ORIGINS into a single CORS_ORIGIN. Variables the 2.x template no longer has are dropped; they remain in the backup folder.
  6. Writes COMPOSE_PROJECT_NAME if your project was not called patchmon, so the existing data volumes are reused.
  7. If the old frontend service was published on a port other than 3000, sets PORT to that port.

The script does not start the new stack, and it does not back up your database.

Before you start

  • Bash, curl, Docker and the Docker Compose plugin on the host.

  • Back up the database while the 1.4.2 stack is still running. The 2.0.0 server migrates the schema on first start, so restoring the old compose files alone is not enough to go back:

    docker compose exec -T database pg_dump -U patchmon_user -d patchmon_db > patchmon-1.4.2.sql
    

    Check that the file is not empty before you continue.

Run the migration

From your PatchMon compose directory (the one containing docker-compose.yml and .env):

curl -fsSL -o migrate1-4-2_to_2-0-0.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/tools/migrate1-4-2_to_2-0-0.sh
less migrate1-4-2_to_2-0-0.sh
bash migrate1-4-2_to_2-0-0.sh

You can also pass the compose directory as the first argument instead of running it from there. Review the new .env and docker-compose.yml, then start the stack:

docker compose up -d

Verify

  • docker compose ps shows server, database, redis and guacd running, and server becomes healthy.
  • docker compose logs server contains [migrate] applied successfully and no errors after it.
  • You can log in with your existing account, and your hosts, packages and history are present.
  • Agents reconnect and their last check-in time updates.

If the server exits straight after [migrate] applied successfully, check the server logs (see Troubleshooting below) and that REDIS_PASSWORD and the database settings in .env are set.

Roll back

If the script fails part-way, it prints the commands to restore your original files from the backup folder. If 2.0.0 has already started against your database, restore the database as well:

docker compose down
cp backup-1-4-2-<timestamp>/docker-compose.yml docker-compose.yml
cp backup-1-4-2-<timestamp>/.env .env
docker volume rm <project>_postgres_data
docker compose up -d --wait database
docker compose exec -T database psql -U patchmon_user -d patchmon_db < patchmon-1.4.2.sql
docker compose up -d

Replace <project> with your Compose project name (patchmon unless you changed it; docker volume ls shows it). If you had no .env before migrating, delete the new one instead of copying it back.

Clean up

The agent_files and branding_assets volumes from 1.4.2 are no longer used. Once you are sure 2.0.0 is working, remove them with docker volume rm <project>_agent_files <project>_branding_assets. Keep patchmon-1.4.2.sql until then as well.

Why the newest version can take a day to appear

Publishing a release and announcing it to running instances are two separate steps.

Your instance checks for a newer server version once a day, and that check reads a value we only update after a release has been through our phased rollout. So for roughly the first 24 hours after a new version appears on GitHub, it is normal for PatchMon to keep showing an older version as the latest available, and for the server_update alert not to have fired yet. Nothing is wrong with your install. It will pick the new version up on a following daily check.

The delay is deliberate. Releasing in phases means a version is running in the wild for a while before every instance is told about it, so any problem is caught before the whole community is prompted to upgrade.

If you would rather not wait, pull the new image whenever you like. The version check only controls the notification, never your ability to upgrade.


Reverse Proxy Setup

PatchMon listens on port 3000 inside the container, mapped to port 3000 on the host by default. Point your reverse proxy at http://<host>:3000.

When deploying behind a reverse proxy, ensure:

  • WebSocket connections are proxied correctly. PatchMon uses WebSockets for agent communication, SSH terminal, and RDP sessions.
  • The X-Forwarded-For, X-Forwarded-Proto, and Host headers are forwarded so PatchMon can construct correct URLs

If you use Nginx Proxy Manager, enable "Websockets Support" on the proxy host entry.


Troubleshooting

Server fails to start: database connection refused

The server waits for the database health check before starting. It then retries its first connection to the database, and then to Redis, logging a [startup] database not ready (attempt n/max) or [startup] redis not ready line for each failure, up to PM_DB_CONN_MAX_ATTEMPTS times, PM_DB_CONN_WAIT_INTERVAL seconds apart. When the connection is refused this takes about a minute by default. Each attempt can also wait up to DB_CONNECT_TIMEOUT seconds, so a database or Redis that drops packets instead of refusing them can take several minutes (up to about six each with the defaults) before the server gives up. A wrong password, a database that does not exist or a certificate error stops it straight away. If it still fails:

# Check that the database container is healthy
docker compose ps

# Check database logs
docker compose logs database

Verify that POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB in your .env are set and consistent.

Server fails to start: Redis connection refused

docker compose logs redis

Verify that REDIS_PASSWORD in your .env is set. The Redis container uses this value in its startup command (--requirepass).

At startup the server logs a connecting to redis line whose addr field shows the Redis address it resolved (for example redis:6379), and the same address appears in the [fatal] redis line if the connection fails. If it shows localhost:6379, REDIS_HOST is not reaching the server container: check the variable is set in .env (or the service's environment: block) and recreate the container.

Server exits straight after "[migrate] applied successfully"

The server stopped on a fatal startup error, most often Redis being unreachable or rejecting REDIS_PASSWORD. Fatal startup errors are always printed to the container output as a line starting [fatal], even when ENABLE_LOGGING=false, so check docker compose logs server for it.

In-browser RDP stops working after a host reboot (guacd shows Exited)

On 2.1.6 and earlier, the server could exit when the host rebooted because the database was not yet accepting connections, and Docker then left guacd stopped, since it shares the server container's network. docker compose ps -a shows guacd as Exited. Bring it back with:

docker compose up -d

or docker start <guacd container name>. From 2.1.7 the server waits for the database and Redis at startup, so this no longer happens.

Checking server logs

# Follow live logs
docker compose logs -f server

# Last 100 lines
docker compose logs --tail=100 server

Resetting to a clean state

Warning: This deletes all PatchMon data. Only do this on a fresh install where you want to start over.

docker compose down -v
docker compose up -d

Port 3000 already in use

Change the host-side port in docker-compose.yml:

ports:
  - "8080:3000"   # Expose on host port 8080 instead

Update SERVER_PORT in your .env to match if agents need to reach the server directly on that port.