Skip to main content

Production Deployment

Do not run production on the default profile

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.

Both secrets are permanent

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​

VariableDescription
SPRING_PROFILES_ACTIVEMust be prod. Selects PostgreSQL, production logging, and the production scan-worker dialect.
SPRING_DATASOURCE_URLJDBC URL, e.g. jdbc:postgresql://host:5432/riskscanner.
SPRING_DATASOURCE_USERNAMEDatabase user.
SPRING_DATASOURCE_PASSWORDDatabase password.
FINDING_FINGERPRINT_HMAC_SECRETSecret used to derive finding fingerprints. The prod profile refuses to start without it. See the warning above.
JIRA_CREDENTIAL_ENCRYPTION_KEYBase64-encoded 256-bit AES key used to encrypt the stored Jira and SMTP credentials at rest.
OIDC_ISSUER_URIIssuer URI of your OIDC provider. See Identity Provider.
VariableDefaultDescription
APP_BASE_URLhttp://localhost:8080Public 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_IDsensitive-data-scanner-frontendPublic 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_ROLEADMINRole name that grants admin access.
APP_SECURITY_USER_ROLEUSERRole name that grants ordinary access. Accounts with neither role are refused.
APP_SECURITY_ROLES_CLAIMrealm_access.rolesJWT claim path containing roles.

Retention​

These control the retention purge.

VariableDefaultDescription
RETENTION_WINDOWP10YISO-8601 period for which retention-managed data is kept. A zero or negative period is refused at startup.
RETENTION_PURGE_AUDIT_EVENTSfalseWhether 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.

VariableDefault
SCAN_ATTACHMENTS_MAX_DOWNLOAD_BYTES26214400 (25 MB)
SCAN_ATTACHMENTS_PARSE_TIMEOUT60s
SCAN_ATTACHMENTS_MAX_EXTRACTED_CHARS20000000
SCAN_ATTACHMENTS_MAX_ARCHIVE_ENTRIES1000
SCAN_ATTACHMENTS_MAX_ARCHIVE_BYTES52428800 (50 MB)
SCAN_ATTACHMENTS_MAX_PARSE_THREADS20

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 HEALTHCHECK against the same endpoint.
  • Application logs go to stdout. On the prod profile the root logger is at WARN and com.projectriskscanner at INFO.

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.
Orphaned scans are cleaned up automatically

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.