Skip to content

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 to docker logs via 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. Point ENFORCEGATE_CAPTIVE_DATABASE_URL at 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:

  1. 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. The cp -n semantics protect operator-modified files and prior bootstrap state.
  2. 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.
  • .env holds 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):

eghost up
eghost logs

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):

eghost policy new 50-my-policy        # opens $EDITOR; auto-compiles + reloads on save

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

docker compose pull           # pull the new image tags
eghost up                     # roll the stack onto the new images

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 files 0640 so Squid (member of the enforcegate group) can read squid-connector.conf and the bump CA.
  • /etc/enforcegate/license/0700 (engine-only — APM keypair + control-server pubkey live here).
  • /etc/enforcegate/ssl/0700, key.pem 0600 (engine's Defendr private key — never group-readable).
  • /etc/enforcegate/ssl-bump/{ca.pem,ca.key}enforcegate:enforcegate 0640 (Squid reads via group; chowned to enforcegate by init-ssl-bump.sh).
  • /var/lib/enforcegate/engine.db0600 (engine only).
  • /var/log/enforcegate/enforcegate:enforcegate 0770 so Squid (group member) can write enforcegate-squid-connector.log. The connector log file itself is squid: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:ro will see an empty volume until the engine's seed-defaults runs. Use depends_on: (with condition: service_healthy) to gate them on engine readiness — see the shipped docker-compose.yml for 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 -n semantics 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.