Persistence¶
How operator-mutable state survives container removal and image upgrades.
Volumes¶
Four named volumes back the standalone bundle. Two carry the engine's persistent state; one carries the shared state between the engine and the sidecars; one carries the TLS terminator's runtime data dir.
| Volume | Container path | Contents | Who writes it |
|---|---|---|---|
enforcegate-config |
/etc/enforcegate/ |
engine.conf, squid-connector.conf, egctl.conf, egpolicy.conf, passwd, deny.list, ssl/{key,cert,ca,dhparams}.pem, ssl-bump/{ca.pem,ca.key}, license/*, rules.d/*.policy, audit/ssl-inspect-ack.log |
Engine (TLS material, license keys), init-ssl-bump (bump CA on first boot), operator (policy drop-ins, conf overrides) |
enforcegate-data |
/var/lib/enforcegate/ |
engine.db (compiled-policy DuckDB), apm-last-error.json (engine APM diagnostics) |
Engine (compiled by compile-default-policy on first boot from rules.d/) |
enforcegate-shared |
/etc/enforcegate-shared/ |
captive-portal-secret (engine ⇄ portal shared secret), portal.{crt,key} (portal TLS leaf signed by the bump CA), squid-ca.crt (operator-facing copy of the bump CA) |
Engine (generate-engine-key mints the secret on first boot; generate-portal-cert mints/renews the cert). Mounted RO into the portal + TLS-terminator sidecars. |
enforcegate-tls |
/data |
TLS-terminator runtime data dir — ACME state, internal CA, lock files. Reserved for future ACME use; mostly empty today. | tls-terminator service |
What deliberately does not persist:
/var/log/enforcegate/— logs flow todocker logsvia the container log pipeline; on-disk files are duplicates. Bind-mount this path if your collector needs file-level access./run/enforcegate/— runtime sockets. Lives on the container's tmpfs by convention./etc/squid/squid.conf— Squid's main config. Lives at its native path; to override, bind-mount the file directly:-v /path/to/squid.conf:/etc/squid/squid.conf:ro./app/instance/captive-portal.db— the portal's SQLite database is on container tmpfs by default and wipes on restart. PointENFORCEGATE_CAPTIVE_DATABASE_URLat a volume-backed SQLite path or a Postgres URL for persistence.
Skel-copy pattern¶
The image ships its default /etc/enforcegate/* tree under a parallel path outside the volume mount point:
/opt/enforcegate/skel/
└── etc/
└── enforcegate/
├── engine.conf
├── squid-connector.conf
├── egctl.conf
├── egpolicy.conf
├── passwd
├── deny.list
├── license/
│ └── cs.key.pub
└── rules.d/
From 2026.32.0 onward rules.d/ ships empty — the no-match verdict is engine-synthesised from [policy].default_action (default "permit"), so the historical catch-all 99-default-permit.policy skel file is retired. Operators upgrading from pre-2026.32.0 deployments may still have a hand-or-skel-seeded catch-all rule in their persistent rules.d/ — that file is no longer needed and continues to occupy a low rule id until removed; deleting it and relying on default_action is the recommended migration.
On every container boot, the seed-defaults boot one-shot runs before any other one-shot that touches persistent state:
integrity check → seed defaults → apply env overrides → generate engine key → generate SSL certs → init SSL-bump → generate portal cert → compile default policy → engine → squid → ready
seed-defaults does two passes:
- File-level no-clobber copy. For every file under
/opt/enforcegate/skel/, copy it into the live tree if and only if the destination doesn't exist. Thecp -nsemantics protect operator-modified files and prior bootstrap state. - Section-level merge for TOML-style configs (files containing
^[…]section headers). For every section the skel version declares, the live version is checked: if the section is missing entirely, or present-but-empty (header line, no body), the skel section block is appended verbatim. Operator content within an already-populated section is never touched. Each merged section emits a[ WARN ] Merged [<name>] into …line on the boot card.
| Scenario | What seed-defaults does |
|---|---|
| First boot, empty volume | Pass 1 copies every default in. Pass 2 is a no-op (skel was written verbatim). Downstream one-shots see a populated /etc/enforcegate/. |
| Subsequent boot, same image | Pass 1 no-clobber. Pass 2 finds every section already populated, no-op. Operator overrides preserved. |
| Image upgrade with a new default file | Pass 1 adds the file. Operator's existing files unchanged. |
| Image upgrade with a new section in an existing file | Pass 1 skips the file (already exists). Pass 2 detects the missing section, appends — operator's other sections untouched. |
| Image upgrade with a changed value in an existing section | Pass 1 + Pass 2 both leave it alone (preserves operator overrides). Use the .env-override path for runtime-tunable keys; see .env overrides. |
The boot-card line tells you which path was taken:
[system] Seeding image defaults (8 new files) ...................... [ OK ] ← first boot
[system] Image defaults already in volume .......................... [ SKIP ] ← steady state
[system] Seeding image defaults (1 new files) ...................... [ OK ] ← upgrade with one new default
[system] Merged [captive_portal] into /etc/enforcegate/engine.conf . [ WARN ] ← section appended on upgrade
.env overrides¶
The section-merge above intentionally never touches a populated section. A schema bump that changes the value of an existing key (e.g. [captive_portal] base_url = "http://localhost:8000" → "https://localhost") won't propagate to operator volumes. The environment-override path closes that gap by inverting the priority between image defaults and .env:
engine.conf(image-shipped + operator-edited) holds defaults..envholds overrides — re-applied on every boot. Each applied override emits a[ WARN ] Override: [section] key = "value" (from env)line on the boot card.
Today's override table:
.env variable |
Rewrites |
|---|---|
ENFORCEGATE_CAPTIVE_BASE_URL |
[captive_portal] base_url |
ENGINE_LICENSE_SERIAL |
[license] serial |
ENGINE_LICENSE_USERNAME |
[license] username |
ENGINE_LICENSE_PASSWORD |
[license] password (length-only on the boot card) |
ENGINE_CONTROL_API_IP |
[control] ip |
ENGINE_APM_DIAGNOSTIC_FILE |
top-level diagnostic_file |
.env is the operator path for runtime-tunable engine.conf keys. Hand-editing the persisted engine.conf still works but loses the audit trail visible on the boot card.
Operator workflows¶
Day-one install¶
bundles/standalone/docker-compose.yml is the ready-to-run shape. After installing the eghost operator CLI (see Docker install):
All four named volumes are created automatically by Docker on first up. Subsequent up / down cycles reuse them — TLS material, license keys, custom rules, the shared secret, and the portal's leaf cert all survive.
Adding a custom policy¶
Two options:
A. eghost policy (recommended):
For a policy already authored elsewhere, copy it into the container and trigger a manual reload:
docker cp my-policy.policy enforcegate:/etc/enforcegate/rules.d/50-my-policy.policy
docker exec enforcegate egpolicy load
egpolicy load recompiles the policy DB and notifies the engine to reload — no container restart required.
B. Direct bind-mount of rules.d/ (heavy customisation):
In docker-compose.yml, replace the enforcegate-config volume with a bind mount:
volumes:
- /path/on/host/enforcegate-config:/etc/enforcegate
- enforcegate-data:/var/lib/enforcegate
The host directory must contain a populated config tree before first boot (copy from a previous container, or run once with the named volume and docker cp the contents out).
Backup and restore¶
# Snapshot every volume to tarballs (containers can stay up; named volumes are tar-safe):
for v in enforcegate-config enforcegate-data enforcegate-shared enforcegate-tls; do
docker run --rm -v "${v}":/data alpine tar czf - -C /data . > "${v}.tgz"
done
# Restore onto a fresh host:
for v in enforcegate-config enforcegate-data enforcegate-shared enforcegate-tls; do
docker volume create "${v}"
docker run --rm -v "${v}":/data -v "$PWD":/backup alpine tar xzf "/backup/${v}.tgz" -C /data
done
docker compose up -d
Image upgrade¶
The new image's seed-defaults one-shot runs on first boot of the upgraded container, adding any new default files that weren't in the old image. TLS material, license keys, custom rules, and modified engine.conf / squid-connector.conf are preserved.
If a major upgrade changes the shape of a default file in a way that needs to take effect (rare), the operator path is:
eghost down
docker volume rm enforcegate-config # only config wiped; engine.db stays
eghost up # seed-defaults re-populates fresh defaults
enforcegate-data (the compiled policy DB) is also safe to wipe — it just forces a re-compile from rules.d/ on next boot.
Factory reset¶
eghost down
docker compose -f /opt/enforcegate/bundles/standalone/docker-compose.yml down -v
eghost up
Equivalent to a factory reset: fresh keys, fresh certs, default rule set. All operator state, including the bump CA private key and the SSL-inspection audit log, is destroyed.
File ownership¶
seed-defaults re-applies file ownership and modes on every boot, so the canonical reference is:
/etc/enforcegate/→enforcegate:enforcegate 0750, default files0640so Squid (member of theenforcegategroup) can readsquid-connector.confand the bump CA./etc/enforcegate/license/→0700(engine-only — APM keypair + control-server pubkey live here)./etc/enforcegate/ssl/→0700,key.pem0600(engine's Defendr private key — never group-readable)./etc/enforcegate/ssl-bump/{ca.pem,ca.key}→enforcegate:enforcegate 0640(Squid reads via group;chowned toenforcegatebyinit-ssl-bump.sh)./var/lib/enforcegate/engine.db→0600(engine only)./var/log/enforcegate/→enforcegate:enforcegate 0770so Squid (group member) can writeenforcegate-squid-connector.log. The connector log file itself issquid:enforcegate 0640./var/spool/squid/→squid:squid(Squid's private cert-DB).
Adding new state? Follow the same rule: engine-only state stays enforcegate:enforcegate 0600/0700; anything Squid needs to read goes 0640 group-readable.
Multi-image future¶
The same volumes carry over verbatim to a future multi-image deployment. The engine container mounts enforcegate-config rw and enforcegate-data rw; every other service mounts enforcegate-config ro and reads what the engine bootstrapped. Sketch:
services:
engine:
image: enforcegate/engine:<v>
volumes:
- enforcegate-config:/etc/enforcegate
- enforcegate-data:/var/lib/enforcegate
squid-connector:
image: enforcegate/squid-connector:<v>
volumes:
- enforcegate-config:/etc/enforcegate:ro
depends_on: [engine]
captive-portal:
image: enforcegate/captive-portal:<v>
environment:
ENGINE_INTERNAL_URL: https://engine:11225
ENGINE_SHARED_SECRET: ${ENGINE_SHARED_SECRET}
DATABASE_URL: postgresql+psycopg://portal:portal@captive-portal-db:5432/captive_portal
ports: ["8000:8000"]
depends_on: [engine, captive-portal-db]
captive-portal-db:
image: postgres:16-alpine
volumes:
- captive-portal-db:/var/lib/postgresql/data
The engine's seed-defaults + generate-ssl-certs + generate-engine-key + compile-default-policy one-shots still run inside the engine container only. Other services pick up the bootstrapped state via the shared volume.
Caveats¶
- First-boot ordering. In multi-image deployments, downstream services that mount
enforcegate-config:rowill see an empty volume until the engine'sseed-defaultsruns. Usedepends_on:(withcondition: service_healthy) to gate them on engine readiness — see the shippeddocker-compose.ymlfor the standalone bundle. - Skel growth. Every file in
/opt/enforcegate/skel/ships in the image and gets copied on every fresh-volume first boot. Keep skel lean — large data shouldn't live there; ship via a separate volume or a download-on-first-boot one-shot instead. cp -nsemantics on directories. Only files are protected from clobber; directories are merged. If a future image renames a default file, the old file in the volume persists alongside the new one until the operator manually cleans up. Document such renames in upgrade notes.