Production Deployment
Started without SPRING_PROFILES_ACTIVE=prod, the scanner boots on an
in-memory H2 database. It works, it scans, and it looks completely
normal - but every finding, scan run, rule, suppression, audit event, and the
stored Jira connection are lost on restart, without a warning. The compose
file below sets the profile for you; do not remove it.
1. Provision PostgreSQL
Create an empty database and a user that owns it. The scanner creates and migrates its own schema with Flyway on startup.
CREATE DATABASE riskscanner;
CREATE USER riskscanner WITH ENCRYPTED PASSWORD '<strong-password>';
GRANT ALL PRIVILEGES ON DATABASE riskscanner TO riskscanner;
Do not pre-create tables, and do not run Flyway yourself.
2. Generate the secrets
Two secrets must be generated once and then kept stable for the lifetime of the installation.
openssl rand -base64 32
openssl rand -base64 48
The first is JIRA_CREDENTIAL_ENCRYPTION_KEY and must decode to exactly 32
bytes - the application refuses to start otherwise, and an arbitrary passphrase
will not work. The second is FINDING_FINGERPRINT_HMAC_SECRET.
Changing JIRA_CREDENTIAL_ENCRYPTION_KEY makes the stored Jira credential
undecryptable; the connection has to be entered again. Changing
FINDING_FINGERPRINT_HMAC_SECRET changes the identity of every finding, so the
next scan reports existing findings as new ones and loses their review history
and suppression matches. Store both in your secret manager, and back them up
with the database.
3. Deploy
Put the secrets in a .env file next to the compose file rather than in the
compose file itself:
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -base64 24)
JIRA_CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -base64 32)
FINDING_FINGERPRINT_HMAC_SECRET=$(openssl rand -base64 48)
EOF
chmod 600 .env
services:
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_DB=riskscanner
- POSTGRES_USER=riskscanner
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?set it in .env}
volumes:
- scanner-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U riskscanner -d riskscanner"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
scanner:
image: arcsoftklg/sensitive-data-scanner:1.0.0 # pin a real tag
depends_on:
postgres:
condition: service_healthy
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/riskscanner
- SPRING_DATASOURCE_USERNAME=riskscanner
- SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD:?set it in .env}
- JIRA_CREDENTIAL_ENCRYPTION_KEY=${JIRA_CREDENTIAL_ENCRYPTION_KEY:?set it in .env}
- FINDING_FINGERPRINT_HMAC_SECRET=${FINDING_FINGERPRINT_HMAC_SECRET:?set it in .env}
- OIDC_ISSUER_URI=https://idp.example.com/realms/sensitive-data-scanner
- APP_BASE_URL=https://riskscanner.example.com
restart: unless-stopped
volumes:
scanner-postgres-data:
docker compose up -d
No Keycloak here: production points at the identity provider you already run.
The :? markers make Compose refuse to start rather than silently substituting
an empty secret.
Environment variables
The compose file above sets these.
Required
| Variable | Description |
|---|---|
SPRING_PROFILES_ACTIVE | Must be prod. Selects PostgreSQL, production logging, and the production scan-worker dialect. |
SPRING_DATASOURCE_URL | JDBC URL, e.g. jdbc:postgresql://host:5432/riskscanner. |
SPRING_DATASOURCE_USERNAME | Database user. |
SPRING_DATASOURCE_PASSWORD | Database password. |
FINDING_FINGERPRINT_HMAC_SECRET | Secret used to derive finding fingerprints. The prod profile refuses to start without it. See the warning above. |
JIRA_CREDENTIAL_ENCRYPTION_KEY | Base64-encoded 256-bit AES key used to encrypt the stored Jira and SMTP credentials at rest. |
OIDC_ISSUER_URI | Issuer URI of your OIDC provider. See Identity Provider. |
Recommended
| Variable | Default | Description |
|---|---|---|
APP_BASE_URL | http://localhost:8080 | Public URL of the scanner. Used to build the links in report e-mails; leave it at the default and recipients get unusable localhost links. |
OIDC_CLIENT_ID | sensitive-data-scanner-frontend | Public OIDC client the web UI authenticates as. Set it when your client is named differently. The web UI reads it, and the issuer, from the scanner at startup - so the published image needs no rebuild for your provider. |
APP_SECURITY_ADMIN_ROLE | ADMIN | Role name that grants admin access. |
APP_SECURITY_USER_ROLE | USER | Role name that grants ordinary access. Accounts with neither role are refused. |
APP_SECURITY_ROLES_CLAIM | realm_access.roles | JWT claim path containing roles. |
Retention
These control the retention purge.
| Variable | Default | Description |
|---|---|---|
RETENTION_WINDOW | P10Y | ISO-8601 period for which retention-managed data is kept. A zero or negative period is refused at startup. |
RETENTION_PURGE_AUDIT_EVENTS | false | Whether the purge also deletes audit events. Off by default so the audit trail survives. |
Attachment scanning limits
These tune attachment scanning and only matter when a scan configuration has it enabled.
| Variable | Default |
|---|---|
SCAN_ATTACHMENTS_MAX_DOWNLOAD_BYTES | 26214400 (25 MB) |
SCAN_ATTACHMENTS_PARSE_TIMEOUT | 60s |
SCAN_ATTACHMENTS_MAX_EXTRACTED_CHARS | 20000000 |
SCAN_ATTACHMENTS_MAX_ARCHIVE_ENTRIES | 1000 |
SCAN_ATTACHMENTS_MAX_ARCHIVE_BYTES | 52428800 (50 MB) |
SCAN_ATTACHMENTS_MAX_PARSE_THREADS | 20 |
Reverse proxy and TLS
The container serves plain HTTP on port 8080 and terminates no TLS itself. Put it behind your own proxy:
server {
listen 443 ssl;
server_name riskscanner.example.com;
ssl_certificate /etc/ssl/certs/riskscanner.crt;
ssl_certificate_key /etc/ssl/private/riskscanner.key;
# Attachment uploads and CSV exports can be large.
client_max_body_size 64m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# A full scan can hold a request open while it starts.
proxy_read_timeout 300s;
}
}
Proxy the whole origin rather than individual paths: the UI is served under
/ui/, the API under /api/, and health under /actuator/health. Set
APP_BASE_URL to the public HTTPS URL, not the internal one.
Air-gapped installation
The scanner needs no internet access at runtime - its only outbound connections are to Jira, your identity provider, your database, and optionally your SMTP server. Licensing is offline and there is no phone-home.
To get the software in, move the image:
docker pull arcsoftklg/sensitive-data-scanner:1.0.0
docker save arcsoftklg/sensitive-data-scanner:1.0.0 | gzip > scanner-1.0.0.tar.gz
docker load < scanner-1.0.0.tar.gz
Then retag it, push it to your internal registry, and point the compose
image: at that.
Health and monitoring
- Health endpoint:
GET /actuator/health. It is the only actuator endpoint exposed, and it never shows details - use it as a liveness and readiness probe. - The image declares its own
HEALTHCHECKagainst the same endpoint. - Application logs go to stdout. On the
prodprofile the root logger is atWARNandcom.projectriskscanneratINFO.
Sizing
- CPU and memory - a single instance with 2 vCPU and 2 GB of heap handles
the default scan engine settings comfortably. Attachment scanning is the
memory-hungry part: raise memory before raising
SCAN_ATTACHMENTS_MAX_PARSE_THREADS. - Database - findings, occurrences, and audit events dominate. Size for the number of issues in scope rather than the number of scans; retention caps unbounded growth at the configured retention window, ten years by default. Audit events are not purged by default, so size for them growing indefinitely.
- Throughput - the limit on scan speed is normally Jira, not the scanner. Tune worker count and max issues per minute per configuration, in Scans.
Running more than one instance
The scanner is single-tenant: one Jira connection, one set of scan configurations, one license. Extra instances are replicas of the same deployment against the same PostgreSQL database, not separate tenants, and all of them must share the same secrets and database.
Replicas buy availability for the UI and the API. They do not buy scan throughput, and they come with limits worth knowing before you scale out:
- Schedules and reports are safe. Firing a scheduled scan and scheduling a report both claim their slot in the database, so two instances will not double-fire a scheduled scan or send the same report twice.
- Sign-in needs no sticky sessions. Authentication carries no server-side session state, so any instance can serve any request.
- A single scan does not spread across instances. Work items are leased from the database, so nothing is ever processed twice, but the workers all run inside the instance that started the scan. More instances will not make one scan faster - raise the worker count instead, in Scans.
- Pattern discovery is instance-local. The content a scan collects for pattern suggestions lives in the memory of the instance that ran the scan. Asking a different instance for suggestions returns partial results or none at all, without an error, and behind a load balancer you do not control which instance answers. The feature is off by default, so this only applies if you turned it on.
If the instance executing a scan stops, the run stops reporting itself alive and is marked failed within about 90 seconds by any instance that is up. It resumes from its last checkpoint without duplicating findings (see Scans). Paused scans are never affected and survive a restart.
A single instance remains the simplest thing to run, and the two limits above mean extra instances buy availability rather than speed. They no longer cost you in-flight scans, though: starting or restarting an instance leaves the scans other instances are executing alone.