What runs
docker compose up -d starts five containers on a private Docker network:
| Container | Purpose | Reachable from outside |
|---|---|---|
frontend | Web interface. Forwards API calls to the backend internally. | Yes, on FRONTEND_PORT |
backend | Application server. Applies database migrations on every start. | No |
postgres | Database (PostgreSQL 16) | No |
redis | Sessions, background jobs (Redis 7) | No |
rustfs | Object 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.
- 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 - Set the public address and secure cookies in
.env:FRONTEND_URL=https://pmis.example.org COOKIE_SECURE=true - Configure the proxy. Example for nginx:
The proxy must setmap $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; } }X-Forwarded-Forto the real client address, replacing any value the client sent, as above. Redirect port 80 to 443. - Apply the changes:
docker compose up -d.
Configuration reference
All settings are in .env. Apply changes with docker compose up -d.
| Variable | Required | Default | Purpose |
|---|---|---|---|
POSTGRES_PASSWORD | Yes | none | Database password. Changing it later requires changing it inside the database too, so set it once. |
MINIO_ROOT_PASSWORD | Yes | none | Secret key of the object storage. The name is kept from earlier versions so existing .env files work. |
JWT_ACCESS_SECRET | Yes | none | Signs sign-in session tokens |
JWT_REFRESH_SECRET | Yes | none | Signs session renewals. Must differ from the access secret. |
RUSTFS_IMAGE | No | rustfs/rustfs:1.0.3 | Object storage image. Pin a version or digest in production. |
ADMIN_PASSWORD | No | empty (random) | First admin password. Used only when the admin account is created. Remove it from .env afterwards. |
SEED_DEMO | No | false | Demo data with public passwords. Never enable on a reachable instance. |
FRONTEND_PORT | No | 3000 | Host port, or address:port, for the web interface |
FRONTEND_URL | No | http://localhost:3000 | The exact address users open. Requests from other origins are refused. |
COOKIE_SECURE | No | false | true behind HTTPS |
MINIO_ROOT_USER | No | pmisminio | Access key of the object storage |
PMIS_IMAGE_REPO, PMIS_VERSION | No | sneakywarwolf, latest | Which 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.
| Store | Compose service / volume | Holds | Back up with |
|---|---|---|---|
| PostgreSQL | postgres / postgres_data | All application data: users, projects, findings, vulnerability library, settings, audit log | pg_dump |
| RustFS (object storage) | rustfs / rustfs_data | Report templates, generated reports, PoC screenshots and other uploaded files | Archive of the volume |
| Redis | redis / redis_data | Sessions, presence and rate-limit counters. Losing it signs everyone out; no project data is stored here | SAVE, then archive of the volume |
.env | project folder | Secrets and settings. The same values must be used after a restore | Copy 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
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
- The Redis step is optional: skip it and everyone simply signs in again.
tarruns as root and keeps the original owners (pflag), which RustFS and Redis need to read their files. Do not unpack the archives as another user.- Open PMIS, sign in and check a project, a PoC image and a generated report download.
- Restoring into a database that already has tables is not supported: always start from
down -vas above.
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
- Installed version. The backend reports
APP_VERSION, which the release workflow sets in each backend image to the release tag it was built from (for example1.0.10). If it is not set, as when you build from source, the version inpackage.jsonis shown instead, and that number is not changed for each release. The About page reads this one value; the frontend has no separate version. - Latest version. The About page asks the GitHub Releases list of
sneakywarwolf/PMIS(override withUPDATE_REPO, orUPDATE_API_URLfor a mirror). Drafts, prereleases and tags that are not plain dotted numbers are ignored. If GitHub cannot be reached or answers with an error, the page says it could not check and keeps the last good result. - Installing updates from the About page is off by default. It only works when you set
UPDATE_COMMAND, a command you provide that the backend runs when an Admin schedules an update. The standard Compose files do not set it, so on a standard install you upgrade as described below. PMIS does not download, verify or roll back anything itself: that is up to your command, so pin versions or digests there. Only an Admin can schedule or cancel an update, and the release text is never passed to the command.
Upgrading
- Back up.
- 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 - Database migrations run automatically when the backend starts. Watch with
docker compose logs -f backenduntil it reports healthy indocker 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:
- No published ports. The
rustfsservice has noports:entry, so neither 9000 nor 9001 is reachable from outside the Docker network. Only thefrontendport is published. - 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.
- Confirm the storage ports are not published. Both commands should print
:0(nothing mapped):docker compose port rustfs 9000 docker compose port rustfs 9001 - Confirm the console is off and the S3 API is up. The first line should print
console:000(connection refused) and the secondhealth: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 - Confirm PMIS still works:
docker compose psshows 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.
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
- The four secrets in
.envare unique random values, and.envis readable only by the operator (chmod 600 .env). - PMIS is served over HTTPS.
FRONTEND_URLis the exact public address.COOKIE_SECURE=true. - The web port is bound to
127.0.0.1, or firewalled so that only the proxy can reach it. - The reverse proxy replaces
X-Forwarded-Forwith the client address. - Access is limited to your organisation's network or VPN where possible.
SEED_DEMO=false.ADMIN_PASSWORDis removed from.envafter the first start.- The admin password was changed at first sign-in, and each administrator has their own named account.
PMIS_VERSIONandRUSTFS_IMAGEare pinned to specific versions.- The database, cache and storage ports are not published. Never use
docker-compose.dev.ymlfor production. - The RustFS console is off (
RUSTFS_CONSOLE_ENABLE: "false") and unreachable from outside. See Keep the RustFS console private. - Backups are scheduled, encrypted and stored off the server, and a restore has been tested.
- The host operating system, Docker and PMIS are kept up to date.