Contact Us

Tell us about your stack and the privacy problems you're trying to solve. We typically respond within one business day.

Prefer email? support@philterd.ai

Please do not enter PII or PHI in this form. If you need to share an example, use a sanitized one.

← All posts

Air-Gapped PII Redaction for Government: A Deployment Walkthrough

Every redaction vendor selling into government says they support air-gapped deployment. Far fewer will tell you what the afternoon actually looks like. You have a room with no route to the internet, a change window, someone from security watching, and a list of artifacts that has to be complete before you walk in, because if you forget one you are not going back out for it today.

This is that list, and the order to do things in.

We have written before about why the architecture works offline and how self-hosted redaction maps to FedRAMP, CMMC, and ITAR. This post assumes you are past that part and have been told to go and do it.

What you are actually moving

Three container images and nothing else. There is no license server to reach, no model download on first run, and no telemetry callback to allowlist. Philter has been Apache 2.0 since version 2.6.0, so there is no key to activate.

Diagram: images are pulled and saved on a connected build host, carried across an audited boundary on transfer media, then loaded into an internal registry inside the air-gapped enclave
The whole procedure is three images, one archive, and one boundary crossing.

The piece that surprises people is PhEye. The NLP models are baked into the image at build time rather than pulled at startup, and each published image carries exactly one model. That is the difference between a service that works in a disconnected enclave and one that appears to work until the first restart.

Step 1: assemble the bundle outside

On a connected staging host, pull the exact versions you intend to run. There is deliberately no latest tag on philterd/philter, which is a small thing that saves you an argument with your own change control later.

docker pull philterd/philter:3.4.0
docker pull philterd/ph-eye:1.3.0-pii_en_small
docker pull mongo:8.2.12

Pin by digest rather than by tag for anything you have to defend to an auditor. A tag is a pointer that can be moved, and a digest cannot.

docker inspect --format='{{index .RepoDigests 0}}' philterd/philter:3.4.0

Save all three into one archive and record its checksum. One archive is easier to account for on a transfer log than three.

docker save -o philterd-bundle.tar \
  philterd/philter:3.4.0 \
  philterd/ph-eye:1.3.0-pii_en_small \
  mongo:8.2.12

sha256sum philterd-bundle.tar > SHA256SUMS

If you plan to run more than one PhEye model, add each image to the same docker save now. Every model is a separate image, and realising you need the medical conditions model after the boundary crossing is a wasted trip.

Step 2: cross the boundary

Follow whatever your program requires for media handling. The only technical advice worth giving is to verify the checksum on both sides of the transfer rather than only on the far side, so that a bad copy is distinguishable from a bad source.

sha256sum -c SHA256SUMS

Step 3: load, then push to the internal registry

docker load -i philterd-bundle.tar

Loading straight onto the host that will run the workload works for a single node and stops working the moment you have three. Retag into the internal registry and let normal deployment tooling take over.

docker tag philterd/philter:3.4.0 registry.internal:5000/philterd/philter:3.4.0
docker push registry.internal:5000/philterd/philter:3.4.0

Step 4: compose the stack with nothing that reaches out

The compose file in the Philter repository builds from source, which is useful for development and wrong here. Reference the loaded images instead, and point the registry prefix at your own.

services:
  philter:
    image: registry.internal:5000/philterd/philter:3.4.0
    depends_on:
      mongodb:
        condition: service_healthy
    environment:
      MONGODB_CONNECTION_STRING: mongodb://mongodb:27017/philter
      PHILTER_ENCRYPTION_KEY: ${PHILTER_ENCRYPTION_KEY:?generate this inside the enclave}
      PHILTER_BOOTSTRAP_API_KEY: ${PHILTER_BOOTSTRAP_API_KEY:-}
    ports:
      - "8080:8080"

  ph-eye:
    image: registry.internal:5000/philterd/ph-eye:1.3.0-pii_en_small

  mongodb:
    image: registry.internal:5000/mongo:8.2.12
    environment:
      MONGO_INITDB_DATABASE: philter
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

A single Philter instance uses a built-in in-memory cache and needs nothing further. If you are running several behind a load balancer, add Valkey to the bundle in step 1 and set CACHE_HOSTNAME, because the in-memory cache is not shared between instances and does not survive a restart.

Step 5: generate secrets on the inside

Philter requires a base64-encoded 32-byte key for encrypting sensitive data at rest. Generate it in the enclave. A key created on the staging host has been outside the boundary, which is a conversation you do not want to have during an assessment.

export PHILTER_ENCRYPTION_KEY=$(openssl rand -base64 32)

Treat it the way you treat any other key of that sensitivity, and note that losing it means losing access to what it protects.

The same applies to the credential your automation will use. Setting PHILTER_BOOTSTRAP_API_KEY seeds a key onto the admin user at first start, which saves a trip to the dashboard on a headless deployment. It has to be sk_ followed by 32 alphanumeric characters.

export PHILTER_BOOTSTRAP_API_KEY="sk_$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32)"

It is seeded only when the admin user has no API keys at all, so it applies to a fresh install and is never resurrected after you create or revoke one of your own. It arrives with every scope, so narrow it or replace it once the deployment is real.

Step 6: first start and a smoke test

Philter generates a self-signed certificate the first time it starts and serves HTTPS on 8080, so curl needs -k until you install your own certificate. Sign in to the dashboard as admin with the password admin, and expect to be made to change it before anything else will work.

docker compose up -d
curl -k https://localhost:8080/api/health

The health endpoint needs no credential, which makes it the right first call. It also reports the redaction policy schema version Philter supports, so it tells you what you are running as well as whether it is up.

Then send one document you already know the answer for. Not a real record, and not a synthetic one so clean that it proves nothing. This call does need the key.

curl -k -X POST https://localhost:8080/api/filter \
  -H "Content-Type: text/plain" \
  -H "Authorization: Bearer $PHILTER_BOOTSTRAP_API_KEY" \
  -d "George Washington lives in 90210 and his SSN was 123-45-6789."

If names come back unredacted while the structured identifiers are caught, PhEye is not reachable from Philter. That is the failure mode to expect in a fresh enclave, and it usually means the service name in compose does not match what Philter is configured to call.

Step 7: prove the gap, do not assume it

The deployment has no outbound dependency, but the useful artifact for an assessor is evidence rather than an assurance from a vendor. Deny egress at the network policy and confirm the stack still comes up clean from a cold start.

docker compose down
docker compose up -d

A restart is the honest test. Anything that was quietly fetched during the first run and cached will fail here rather than in six months when a node is replaced.

Step 8: plan the upgrade before you need it

Upgrades are step 1 through step 3 again, which is worth saying out loud during the initial deployment while everyone is in the room. Keep the previous archive until the new version has run in the enclave for a while, because rolling back is another boundary crossing if you have deleted it.

What to hand the auditor

By the end you have a version-pinned bill of materials with digests, a checksum record for the transfer, an encryption key that has never existed outside the boundary, and a cold-start test taken with egress denied. That set answers most of what a control review asks about a redaction component, and it is considerably easier to assemble on the day than to reconstruct afterwards.

Detection is probabilistic, so the other half of the work is measuring how the policy performs against your own documents. That is a separate exercise from the deployment, and one worth scheduling before the system carries anything that matters.