PMIS documentation

Production & operations

Run PMIS for a team: HTTPS, configuration, backups, upgrades and a checklist to complete before go-live.

What runs

docker compose up -d starts five containers on a private Docker network:

Private Docker network BrowserHTTPS via proxy frontendFRONTEND_PORT backendinternal only postgres redis rustfs
Only the frontend port is published. Everything else stays inside the private network.
ContainerPurposeReachable from outside
frontendWeb interface. Forwards API calls to the backend internally.Yes, on FRONTEND_PORT
backendApplication server. Applies database migrations on every start.No
postgresDatabase (PostgreSQL 16)No
redisSessions, background jobs (Redis 7)No
rustfsObject storage (RustFS, S3-compatible) for templates, reports and screenshots. Its web console is disabled in production (details).No

Data is kept in three Docker volumes: postgres_data, redis_data and rustfs_data. Docker prefixes them with the project name, for example pmis_rustfs_data when the folder is named PMIS. Check with docker volume ls.

HTTPS with a reverse proxy

PMIS serves plain HTTP. Put a reverse proxy in front that terminates TLS.

  1. Bind the web interface to the local machine only, so it can't be reached around the proxy. In .env:
    FRONTEND_PORT=127.0.0.1:3000
  2. Set the public address and secure cookies in .env:
    FRONTEND_URL=https://pmis.example.org
    COOKIE_SECURE=true
  3. Configure the proxy. Example for nginx:
    map $http_upgrade $connection_upgrade { default upgrade; '' close; }
    
    server {
        listen 443 ssl;
        server_name pmis.example.org;
        ssl_certificate     /etc/ssl/pmis/fullchain.pem;
        ssl_certificate_key /etc/ssl/pmis/privkey.pem;
    
        client_max_body_size 512m;   # Nessus scans up to 500 MB; every other upload is far smaller
    
        location / {
            proxy_pass http://127.0.0.1:3000;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $remote_addr;   # replace, don't append
            proxy_set_header X-Forwarded-Proto https;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
        }
    }
    The proxy must set X-Forwarded-For to the real client address, replacing any value the client sent, as above. Redirect port 80 to 443.
  4. Apply the changes: docker compose up -d.

Configuration reference

All settings are in .env. Apply changes with docker compose up -d.

VariableRequiredDefaultPurpose
POSTGRES_PASSWORDYesnoneDatabase password. Changing it later requires changing it inside the database too, so set it once.
MINIO_ROOT_PASSWORDYesnoneSecret key of the object storage. The name is kept from earlier versions so existing .env files work.
JWT_ACCESS_SECRETYesnoneSigns sign-in session tokens
JWT_REFRESH_SECRETYesnoneSigns session renewals. Must differ from the access secret.
RUSTFS_IMAGENorustfs/rustfs:1.0.3Object storage image. Pin a version or digest in production.
ADMIN_PASSWORDNoempty (random)First admin password. Used only when the admin account is created. Remove it from .env afterwards.
SEED_DEMONofalseDemo data with public passwords. Never enable on a reachable instance.
FRONTEND_PORTNo3000Host port, or address:port, for the web interface
FRONTEND_URLNohttp://localhost:3000The exact address users open. Requests from other origins are refused.
COOKIE_SECURENofalsetrue behind HTTPS
MINIO_ROOT_USERNopmisminioAccess key of the object storage
PMIS_IMAGE_REPO, PMIS_VERSIONNosneakywarwolf, latestWhich prebuilt images to use. Pin a version. When you build from source, PMIS_VERSION (for example 1.0.10) is also the version shown on the About page.

Sign-in protection settings

The backend container receives only the variables listed above. To change sign-in lockout or request-rate limits, create docker-compose.override.yml next to docker-compose.yml. Compose reads it automatically.

services:
  backend:
    environment:
      MAX_LOGIN_ATTEMPTS: "5"         # failed sign-ins before a temporary lock
      RATE_LIMIT_PER_MIN: "300"       # requests per minute per signed-in user
      AUTH_RATE_LIMIT_PER_MIN: "30"   # sign-in requests per minute per address

The values shown are the defaults. Lower them for stricter protection.

In-application settings

Application name, logo and favicon, session timeout (default 15 minutes), password policy, retention periods, storage and database quotas, severity labels, display formats and report formats are changed by an Admin under Settings. Every setting is applied by the running application; the application name no longer comes from an environment variable. They are stored in the database and recorded in the settings history.

Backup and restore

A complete backup has four parts: the three data stores that PMIS uses, and your .env.

StoreCompose service / volumeHoldsBack up with
PostgreSQLpostgres / postgres_dataAll application data: users, projects, findings, vulnerability library, settings, audit logpg_dump
RustFS (object storage)rustfs / rustfs_dataReport templates, generated reports, PoC screenshots and other uploaded filesArchive of the volume
Redisredis / redis_dataSessions, presence and rate-limit counters. Losing it signs everyone out; no project data is stored hereSAVE, then archive of the volume
.envproject folderSecrets and settings. The same values must be used after a restoreCopy of the file

Backups contain client vulnerability data. Encrypt them and restrict access.

Commands are for Linux, macOS or Git Bash, run in the PMIS folder. Compose names volumes <project>_<volume>, where the project is the folder name in lower case (pmis for a folder called PMIS). Check the real names with docker volume ls and replace pmis_ below if yours differ. The archive commands use the postgres:16.4-alpine image, which is already on the host, only for tar.

Back up

Stop PMIS from taking new work first, so the three stores match each other: docker compose stop frontend backend. The data stores keep running.

mkdir -p backup && D=$(date +%F)

# 1. PostgreSQL: a consistent logical dump (custom format, compressed)
docker compose exec -T postgres pg_dump -U pmis -d pmis -Fc > backup/pmis-db-$D.dump

# 2. Redis: write a snapshot to disk, then archive the volume
docker compose exec -T redis redis-cli SAVE
docker run --rm -v pmis_redis_data:/data:ro -v "$PWD/backup":/backup postgres:16.4-alpine   tar czpf /backup/pmis-redis-$D.tgz -C /data .

# 3. RustFS: archive the volume (templates, reports, screenshots)
docker run --rm -v pmis_rustfs_data:/data:ro -v "$PWD/backup":/backup postgres:16.4-alpine   tar czpf /backup/pmis-files-$D.tgz -C /data .

# 4. Configuration (keep with the backup, store securely)
cp .env backup/pmis-env-$D.bak

# Start PMIS again
docker compose start backend frontend

Check that the files exist and are not empty: ls -lh backup. For a stronger check, docker compose exec -T postgres pg_restore --list < backup/pmis-db-$D.dump | head should print the archive header and table of contents.

Restore

Restoring replaces all current data. docker compose down -v permanently deletes the current database, files and Redis data. Back up first if you are unsure.

Put the saved .env back in the PMIS folder, then set the date of the backup to restore:

D=YYYY-MM-DD
cp backup/pmis-env-$D.bak .env

# Remove the old stack and its volumes, then create empty containers and volumes (nothing starts yet)
docker compose down -v
docker compose create

# 1. PostgreSQL: start only the database, wait until it is healthy, then load the dump
docker compose up -d postgres
docker compose ps postgres          # wait until it shows (healthy)
docker compose exec -T postgres pg_restore -U pmis -d pmis --no-owner --exit-on-error < backup/pmis-db-$D.dump

# 2. Redis and 3. RustFS: unpack the archives into the (still stopped) volumes
docker run --rm -v pmis_redis_data:/data -v "$PWD/backup":/backup postgres:16.4-alpine   tar xzpf /backup/pmis-redis-$D.tgz -C /data
docker run --rm -v pmis_rustfs_data:/data -v "$PWD/backup":/backup postgres:16.4-alpine   tar xzpf /backup/pmis-files-$D.tgz -C /data

# Start everything
docker compose up -d

Use the same .env that was in place when the backup was taken, in particular the database password, the object storage keys and the JWT secrets.

Versions and the About page

Upgrading

  1. Back up.
  2. Get the new version:
    # Prebuilt images: set PMIS_VERSION in .env to the new release, then
    docker compose pull
    docker compose up -d
    
    # Built from source:
    git pull
    docker compose up -d --build
  3. Database migrations run automatically when the backend starts. Watch with docker compose logs -f backend until it reports healthy in docker compose ps.

Keep the RustFS console private

RustFS ships a web console (port 9001) that signs in with the same access and secret key PMIS uses. PMIS does not need it: the backend talks only to the storage's S3 API on port 9000, over the private Docker network. Anyone who reached the console with those keys could read or delete every report, template and screenshot, so it must not be reachable by users or from the internet.

The production docker-compose.yml protects it in two layers:

  1. No published ports. The rustfs service has no ports: entry, so neither 9000 nor 9001 is reachable from outside the Docker network. Only the frontend port is published.
  2. Console switched off. RUSTFS_CONSOLE_ENABLE: "false" stops the console from listening at all, so no other container on the network can open it either. The S3 API on port 9000 is unaffected, so uploads, downloads, report generation and the health check keep working.

Check your deployment

Run in the PMIS folder with the stack running.

  1. Confirm the storage ports are not published. Both commands should print :0 (nothing mapped):
    docker compose port rustfs 9000
    docker compose port rustfs 9001
  2. Confirm the console is off and the S3 API is up. The first line should print console:000 (connection refused) and the second health:200:
    docker compose exec -T rustfs curl -s -o /dev/null \
      -w 'console:%{http_code}\n' --max-time 5 http://127.0.0.1:9001/
    docker compose exec -T rustfs curl -s -o /dev/null \
      -w 'health:%{http_code}\n' http://127.0.0.1:9000/health
  3. Confirm PMIS still works: docker compose ps shows every service healthy, then upload a template or a PoC screenshot and generate a report.

If your install predates this setting

Either pull the current docker-compose.yml, or add the variable to the rustfs service yourself and recreate it:

    environment:
      RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER:-pmisminio}
      RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
      RUSTFS_CONSOLE_ENABLE: "false"
docker compose up -d rustfs

Recreating the container does not touch the rustfs_data volume. The backend reconnects on its own; if a request fails right after, run docker compose restart backend.

Never publish ports 9000 or 9001 in production, for example with a ports: entry or a proxy rule. docker-compose.dev.yml publishes both with fixed credentials and is for development only. If you must administer storage, run an S3 client such as mc in a container on the Compose network, not through a published port.

Health and logs

docker compose ps                 # status and health of each container
docker compose logs -f backend    # application log
docker compose logs -f frontend

Admins can also see infrastructure health under Settings.

Production checklist