Production Hardening
The default TracePcap configuration is optimised for quick local testing. Before exposing the application to a wider audience, follow these steps.
Checklist
Any deployment beyond local development must:
Override all sample credentials — PostgreSQL, MinIO, and the Keycloak admin.
Restrict exposed services so PostgreSQL and MinIO are not externally reachable.
Terminate TLS at nginx.
Set explicit
CORS_ALLOWED_ORIGINSif the frontend is served from a different origin than the API.Point the LLM at a local inference server, so capture-derived content is never sent to a third party.
Enable authentication via the Keycloak overlay where the deployment is shared.
Choose a retention window. The default deletes captures after 12 hours.
Confirm disk headroom against that window, with room for backups.
Install the backup timer. Nothing is backed up until you do.
Each is covered in detail below.
Spring Profile per Deployment Mode
The backend selects behaviour via SPRING_PROFILES_ACTIVE:
Compose invocation |
Profile |
Notes |
|---|---|---|
|
|
Open quick-start. Swagger on, verbose errors, localhost CORS defaults, DEBUG logging. |
|
|
Air-gapped quick-start. Same dev defaults as the base file. |
|
|
Auth overlay. Strict CORS, Swagger + |
|
|
Offline auth overlay. Same |
The two *-prod.yml overlays set SPRING_PROFILES_ACTIVE=prod on the
backend, overriding the base file’s dev. This is the intended production
path — enabling authentication (below) and the prod profile together.
Under the prod profile CORS defaults to empty (no localhost origins).
The shipped stack is same-origin — nginx serves the SPA and proxies /api on
one origin — so browser→API calls never trigger CORS and no origins need
allowing. Set CORS_ALLOWED_ORIGINS (comma-separated) only if you host
the frontend on a different origin from the API.
Change Default Credentials
Credentials come from .env — there is no need to edit any compose file.
The base stack defaults them to well-known development values
(tracepcap_pass, minioadmin) so a local or CI run works with no .env.
Those defaults are public. The two *-prod.yml overlays therefore require
every credential and abort before starting anything if one is unset.
Important
That check tests only that a value is present, not that it is a good one.
Nothing stops an operator writing tracepcap_pass or minioadmin into
.env — the overlay would start normally. Replace every value below with a
unique secret; the fail-fast only catches the case where you forgot.
Set all of these in .env (see .env.example for the full list):
# First start only — see the warning below before changing these on a
# deployment that already has a database volume.
POSTGRES_DB=tracepcap
POSTGRES_USER=tracepcap_user
POSTGRES_PASSWORD=<strong-unique-value>
MINIO_ROOT_USER=<strong-unique-value>
MINIO_ROOT_PASSWORD=<strong-unique-value> # MinIO requires 8+ characters
KEYCLOAK_ADMIN=<admin-username> # auth overlays only
KEYCLOAK_ADMIN_PASSWORD=<strong-unique-value>
One value feeds every consumer that has to agree on it — the backend, the Postgres healthcheck, and the MinIO bucket bootstrap — so they cannot drift apart.
Warning
All three ``POSTGRES_*`` values above are applied only when the database volume is first initialised, including the password. Changing any of them against an existing deployment does not update PostgreSQL:
POSTGRES_USER/POSTGRES_DB— the old role and database remain, the backend connects with the new name, and startup fails.POSTGRES_PASSWORD— the old password stays active and the new one is silently ignored. This is the more dangerous case, because the deployment looks like it accepted a rotated credential when it did not.
Set them before first start. To change them afterwards, either recreate the
volume (destroying existing data — take a backup first, see
Backup & Restore) or alter the running database and update .env to
match.
To rotate the password, use psql’s \password meta-command. It prompts
for the value and sends it pre-hashed, so the plaintext never reaches your
shell history, the process list, or the server log:
docker exec -it tracepcap-postgres psql -U <current-user> -d <current-db>
\password <user>
\q
Then set the same value as POSTGRES_PASSWORD in .env and restart the
backend so it reconnects with the new credential.
Enable Authentication
The base stack runs with no login. For multi-user or internet-facing deployments, enable the bundled OIDC/Keycloak authentication via the production overlay:
PUBLIC_URL=https://app.example.com \
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
This gates the API behind a Keycloak JWT and adds a login flow to the frontend.
The Keycloak admin account has no default — set KEYCLOAK_ADMIN and
KEYCLOAK_ADMIN_PASSWORD in .env (the overlay aborts otherwise). The realm
still seeds a demo app login of analyst / analyst; change it before
exposing the app. See Authentication (OIDC / Keycloak) for the full
walkthrough.
If you prefer an external identity layer instead, you can still front nginx
with an oauth2-proxy,
nginx auth_basic, or restrict access at the VPN/firewall level.
Configure SSL/TLS
nginx serves plain HTTP by default. Everything the browser sends — including the sign-on token once authentication is enabled — crosses the network unencrypted until you terminate TLS.
Note
The config is baked into the nginx image at build time
(nginx/nginx.conf.template) and rendered by envsubst at container
start. Editing the running container’s /etc/nginx/nginx.conf does not
survive a restart. Use one of the two routes below.
- Step 1 — obtain a certificate
From your internal CA for a private deployment, or Let’s Encrypt if the host is internet-reachable. You need the certificate chain and the private key, e.g.
fullchain.pemandprivkey.pem.- Step 2 — mount the certificate and publish 443
Add to the
nginxservice. Keep the key read-only, and note it must be readable by the nginx worker user inside the container.services: nginx: ports: - "${NGINX_PORT:-80}:80" - "443:443" volumes: - ./certs/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro - ./certs/privkey.pem:/etc/nginx/certs/privkey.pem:ro
- Step 3 — add the HTTPS server block
Edit
nginx/nginx.conf.template. Change the existingserverblock to listen on 443 with TLS, and add a redirect for plain HTTP. Keep every existinglocationblock — they proxy/apiand, under the auth overlay, the sign-on paths.server { listen 80; server_name _; return 301 https://$host$request_uri; } server { listen 443 ssl; http2 on; server_name _; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_session_cache shared:SSL:10m; add_header Strict-Transport-Security "max-age=31536000" always; # ... keep the existing location blocks unchanged ... }
Rebuild so the change lands in the image:
docker compose up -d --build nginx
Offline deployments run a pre-built image and cannot rebuild on the target host. Either bake the change in when you build the image bundle, or bind-mount a finished config over the rendered one:
volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
A bind-mount bypasses
envsubst, so substitute${NGINX_MAX_BODY_SIZE}and${NGINX_PROXY_TIMEOUT}with literal values first, or the upload limit and proxy timeout will be wrong.- Step 4 — update the public origin
With the authentication overlay,
PUBLIC_URLpins the token issuer and the backend’s issuer check. It must match the origin the browser actually loads, scheme included:PUBLIC_URL=https://app.example.com
Getting this wrong fails the redirect or the issuer check rather than falling back gracefully. Recreate Keycloak after changing it. See Authentication (OIDC / Keycloak).
- Step 5 — verify
curl -sI https://app.example.com | head -1 # expect 200 curl -sI http://app.example.com | head -1 # expect 301
Confirm the redirect lands on the same host you set in
PUBLIC_URL, then log in once end to end — a mismatch usually surfaces at the sign-on redirect rather than at the first page load.
Tip
If a reverse proxy, load balancer or Tailscale already terminates TLS in front
of this host, leave nginx on HTTP and set PUBLIC_URL to the external
https:// origin instead. The stack trusts X-Forwarded-* from the proxy.
Adjust Upload Limits
Upload limits are derived from a single memory budget. Set APP_MEMORY_MB in
.env appropriate for your host RAM; the max upload size is 25% of it
(e.g. 4096 → 1 GB upload):
APP_MEMORY_MB=4096 # ~1 GB max upload
Container Resource Limits
Every service declares CPU and memory limits via deploy.resources.limits.
Without them a single large capture can exhaust host memory and take the whole
box down with it.
Why the backend is the one that matters
The backend does not do its packet work inside the JVM. It shells out to
native subprocesses — tshark, ndpi, Suricata, tcpflow — whose
memory lives entirely outside the JVM heap. A container budget therefore has
to cover three things, not one:
Consumer |
Share |
Notes |
|---|---|---|
JVM heap |
~50% |
|
JVM non-heap |
~20% |
Metaspace, thread stacks, code cache, direct buffers (~250–400 MB). |
Native subprocesses |
~30% |
|
This is why the heap is 50% of the budget rather than the 75% used before issue #378. At 75% there was no room left for the native half of the workload; once a hard memory limit exists, that overflow becomes an immediate OOM-kill of the backend container instead of a diffuse host-level memory problem.
How the heap is sized
backend/docker-entrypoint.sh reads the enforced cgroup limit and sets the
heap to 50% of it via -XX:MaxRAMPercentage. It does not simply trust
APP_MEMORY_MB. This matters because the JVM then sizes itself against the
limit the kernel will actually enforce, even if the compose limit and
APP_MEMORY_MB have drifted apart. When the container runs with no memory
limit at all, it falls back to computing -Xms/-Xmx from APP_MEMORY_MB.
The startup banner reports which path was taken:
TracePcap backend starting:
APP_MEMORY_MB = 2048 MB
Memory budget from = cgroup limit (2048 MB)
JVM heap = 1024 MB (50% of budget)
Native headroom = 50% for JVM non-heap + tshark/ndpi/Suricata
Sizing
BACKEND_MEM_LIMIT defaults to APP_MEMORY_MB, so raising the latter raises
the enforced limit in step and the two cannot silently diverge.
If you do set BACKEND_MEM_LIMIT explicitly, the enforced limit wins: the
heap, the max upload size and the analysis timeout are all derived from it rather
than from APP_MEMORY_MB, and the startup banner reports the mismatch. This
matters because the alternative is unsafe — sizing the upload from a larger
APP_MEMORY_MB while the kernel enforces a smaller cap would let a single
upload consume the whole native headroom this section exists to protect.
Service |
Minimum |
Default |
CPU |
Notes |
|---|---|---|---|---|
backend |
2 GB |
2 GB |
4 |
Raise via |
postgres |
512 MB |
1 GB |
2 |
Grows with retained analysis history. |
minio |
512 MB |
1 GB |
2 |
Streams objects; not upload-size bound. |
nginx |
128 MB |
256 MB |
1 |
Buffers request bodies, but large ones spill to disk in |
keycloak |
512 MB |
1 GB |
2 |
Auth overlays only. |
Totals ~4.4 GB for the default stack, ~5.4 GB with an auth overlay
(including the 128 MB minio-init bootstrap job).
Every value is overridable, e.g. for a 4-core / 8 GB host:
APP_MEMORY_MB=4096 # backend budget + enforced limit, 2 GB heap
BACKEND_CPU_LIMIT=3
POSTGRES_MEM_LIMIT=1g
MINIO_MEM_LIMIT=512m
Note
deploy.resources.limits is honoured by docker compose up — it is not
Swarm-only. deploy.resources.reservations (CPU/memory requests) is
Swarm/Kubernetes-only and is deliberately not used here; requests are a
scheduling concept with no meaning on a single Compose host.
Plan Storage & Retention
Container limits bound memory and CPU. Disk is bounded by retention, and it is the one resource the stack will happily consume until it runs out — which takes the service down and breaks the next backup with it.
Danger
The shipped default deletes analysis captures after 12 hours. That is deliberate for local testing and wrong for most real deployments. Decide the window before go-live rather than discovering it when a capture disappears.
- Step 1 — choose a retention window
Set these in
.env. See Environment Variables for the full table.FILE_RETENTION_ENABLED=true # false keeps everything, forever FILE_RETENTION_HOURS=12 # analysis captures MONITOR_FILE_RETENTION_HOURS=0 # monitor snapshots; 0 = never expire PACKET_RETENTION_HOURS=0 # 0 = packets live as long as the capture
For evidence preservation or air-gapped audit work, set
FILE_RETENTION_ENABLED=false. That alone is sufficient — the clean-up scheduler is then never registered, so nothing below it can delete anything.Warning
0means “never” only forMONITOR_FILE_RETENTION_HOURSandPACKET_RETENTION_HOURS. ForFILE_RETENTION_HOURSit means delete everything now. To disable deletion useFILE_RETENTION_ENABLED=false, neverFILE_RETENTION_HOURS=0.- Step 2 — size the disk against that window
Storage runs to roughly 2.5x the capture volume ingested — 100 GB of captures needs about 250 GB of disk: the 100 GB of files themselves, plus 100–150 GB of database built from them. With file retention on, the capture-derived working set stops growing (monitor snapshots are exempt — see the note at the end of this section):
steady state ~= ingest_per_day x 2.5 x (FILE_RETENTION_HOURS / 24)
So 20 GB of captures a week (~2.9 GB/day) at the default 12-hour window holds about 3.6 GB. With
FILE_RETENTION_ENABLED=falsethere is no steady state and the disk is the only limit — size it for the full retained corpus. Database Scalability works both directions, including the disk-to-window inverse.- Step 3 — leave headroom for backups
scripts/backup.shstages an uncompressed copy before archiving, so a run needs roughly twice the live data set free. A full disk breaks backups at exactly the moment they matter.- Step 4 — check it, and keep checking
bash scripts/capacity.shReports current usage and projects when the disk fills, from the observed ingest rate. It is read-only and safe on a live deployment; the exit code makes it usable as a cron canary. See Database Scalability for thresholds.
Note
Monitor snapshots do not expire by default. They are exempt from
FILE_RETENTION_HOURS because a monitor network is a time series and
expiring its snapshots destroys the drift history. On a monitor-heavy
deployment they are therefore the component that actually accumulates — check
them first when disk grows unexpectedly.
Set Up Backups
Danger
No backup runs until you install the timer. scripts/backup.sh is a
standalone script — it is not wired into Docker Compose, the application, or
any scheduler. A fresh deployment has no backups at all, however long it has
been running.
The scripts and unit files ship in scripts/; installing them is a deliberate
step:
sudo cp scripts/tracepcap-backup.service scripts/tracepcap-backup.timer \
/etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now tracepcap-backup.timer
Edit User, WorkingDirectory and BACKUP_DIR in the .service file
first so they match this deployment. Then confirm it is actually scheduled —
copying the files is not enough on its own:
systemctl list-timers tracepcap-backup.timer
Point BACKUP_DIR at storage on a different disk from the deployment. A
backup on the same disk does not survive that disk failing, which is the main
thing it exists for.
Important
Rehearse the restore before the deployment holds data you care about. An untested backup is not a backup, and the restore is the one procedure you do not want to be performing for the first time during an incident. The rehearsal is a documented five-step drill in Backup & Restore.
Full detail — what is captured, recovery objectives, the cron alternative, and what is deliberately not covered — is in Backup & Restore.
Restart Policies
All long-running services set restart: unless-stopped so a crashed container
comes back automatically and the stack survives a host reboot. The minio-init
bucket-bootstrap job is deliberately excluded — it is meant to run once and exit
0, and a restart policy would loop it forever.
To stop a service and have it stay stopped, use docker compose stop <svc>;
unless-stopped honours a manual stop across daemon restarts.
Configure LLM Privacy
AI features (Story mode, Network Insights) build their prompts from analysed capture content, so the LLM endpoint is a data egress path.
Both production overlays require LLM_API_BASE_URL and abort if it is
unset, rather than falling back to a default — the online overlay would otherwise
inherit api.openai.com from the base file, and the offline overlay
localhost. Point it at a locally-hosted inference server (LM Studio, Ollama,
vLLM):
LLM_API_BASE_URL=http://<your-inference-host>:1234/v1
Warning
Use the address of the inference host as reachable from the backend
container. localhost resolves to the container itself, not to a server
running on the Docker host or elsewhere on the LAN, so it will fail at request
time rather than at startup.
Do not configure a cloud API endpoint if your PCAP data is sensitive.
The production overlays also default GEO_FORCE_OFFLINE to true, resolving
geolocation from the bundled DB-IP Lite MMDB instead of probing ipinfo.io at
runtime. Set GEO_FORCE_OFFLINE=false only if outbound lookups are acceptable
and the deployment has internet access.
Restrict MinIO Console Access
The MinIO console is exposed on port 9001 by default. Remove or restrict
this port in docker-compose.yml for production:
# Comment out or remove:
# ports:
# - "9001:9001"