Skip to content

Deployment

Build

mvn package

This runs the tests and produces a runnable target/philter-router.jar (Java 25).

Run

java -jar philter-router.jar /path/to/router.yaml

The router starts whatever the configuration enables: the HTTP API (server), folder watching (watch.locations), or both. To also write rolling log files, set router.log.dir:

java -Drouter.log.dir=/var/log/philter-router -jar philter-router.jar router.yaml

On Windows the same jar runs as a Windows Service under a service wrapper; the HTTP API and folder watching behave identically.

Docker

The Dockerfile copies the prebuilt jar onto a JRE base image and runs it as a non-root user, so build the jar first with mvn package. The configuration is mounted at /config/router.yaml, and the watched and output directories under /data.

mvn package
docker build -t philterd/philter-router .
docker run --rm \
  -p 8080:8080 \
  -v "$PWD/config:/config:ro" \
  -v "$PWD/data:/data" \
  philterd/philter-router

JAVA_OPTS is passed through, for example -e JAVA_OPTS=-Drouter.log.dir=/data/logs.

Building a release image

build-image.sh builds the image for linux/amd64 and linux/arm64. push-image.sh publishes it. Both take an optional version, defaulting to the Maven project version in pom.xml.

./build-image.sh 1.0.0
./push-image.sh 1.0.0

build-image.sh runs mvn package first, since the Dockerfile copies the prebuilt jar, then loads each architecture under its own tag (1.0.0-amd64, 1.0.0-arm64) so both are available locally to run and test. Set SKIP_MVN=1 to image a jar already in target/, and ARCHES to build a single architecture:

ARCHES=amd64 ./build-image.sh

push-image.sh pushes those two tags and joins them into the 1.0.0 tag that users pull. It builds nothing, so what is published is what was built and tested. Publishing is always a manual step run from a machine holding the registry credential; no workflow pushes an image.

The version argument names the image tag only. The version the router reports on GET /api/health comes from the Maven project version, which is why the scripts default to it. Passing a version that differs from the one in pom.xml makes the tag and the reported version disagree.

HTTPS

The container generates a self-signed certificate at start, not at build time, and serves the API over HTTPS, so requests use https:// (for example curl -k https://localhost:8080/api/health). Each container generates its own keypair with a random password. Nothing is baked into the image: a certificate created at build time would be identical in every copy of a published image, so anyone who pulled it would hold the private key for every deployment running the default.

The practical consequences are that the certificate changes when the container is recreated, and that a client pinning the certificate needs it re-pinned. To keep one certificate across restarts, mount a volume at /home/router/tls and set ROUTER_KEYSTORE_PASSWORD so the existing keystore is reused; the router fails to start if a keystore is present and that variable is missing, rather than silently generating a second one. ROUTER_KEYSTORE overrides the keystore path.

A named volume inherits that directory's ownership from the image and works as is. A bind mount from the host does not, so chown 10001 the host directory first or the container cannot write the keystore.

The generated certificate carries localhost, philter-router, and 127.0.0.1 as subject alternative names, so it can be verified against those names rather than only with -k.

To use your own certificate, mount a PKCS12 keystore and override the SSL settings, or disable HTTPS with -e SSL_OPTS=:

docker run --rm -p 8080:8080 \
  -e SSL_OPTS="-Dserver.ssl.enabled=true -Dserver.ssl.key-store=/config/keystore.p12 -Dserver.ssl.key-store-password=secret -Dserver.ssl.key-store-type=PKCS12 -Dserver.ssl.key-alias=myalias" \
  -v "$PWD/config:/config:ro" -v "$PWD/data:/data" \
  philterd/philter-router

Setting SSL_OPTS to anything, including the empty string, hands TLS configuration entirely to you: the entrypoint then generates no certificate and sets no SSL properties of its own.

Docker Compose

docker-compose.yml runs the router. A sample config/router.yaml ships in the repo, so after building the jar it comes up out of the box and reports healthy:

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

Edit config/router.yaml to point the engine URLs at your Philter engines (see Configuration); the sample routes everything to the default policy. The router logs to stdout by default (docker compose logs); uncomment the JAVA_OPTS line in the Compose file to also write rolling files under ./data.

A local Ollama for the LLM classifier is opt-in via the ollama profile, so a plain up starts only the router. Start Ollama only when the config uses a classifier, and point the classifier endpoint at http://ollama:11434:

docker compose --profile ollama up -d
docker compose --profile ollama exec ollama ollama pull llama3.1

The Philter engines the router forwards to run as their own services. Add them to the Compose file or point the engine URLs at existing deployments.

Scaling

The two entry points scale differently.

The HTTP API is stateless. Each request is independent: there is no shared or cross-request state, configuration is read-only after startup, and authorization is per request. Run any number of instances behind a load balancer with no session affinity. The routers forward to Philter, so Philter and the classifier become the downstream capacity limit as instances are added.

The folder watcher is single-instance per directory set. Its processed-file ledger is in-memory and per-process, so two watchers on the same directories redact each file twice and contend on the move to the done directory. To scale watching, partition the directories across instances so no two watch the same location, or run a single watcher (or the batch client) that fans out to a pool of stateless API instances.

Multiple Philter engines

An engine names a single url. To run several identical Philter replicas for throughput or failover, front them with a load balancer and point the engine at the balancer:

engines:
  philter1: { url: "http://philter-lb:8080", readTimeoutMs: 300000 }

The router has no built-in engine pool by design. Balancing with health checks is what a load balancer already does, and keeping it external leaves the retry policy in the operator's hands instead of fixing it in the router. A minimal nginx front end:

upstream philter {
    server philter-a:8080 max_fails=3 fail_timeout=30s;
    server philter-b:8080 max_fails=3 fail_timeout=30s;
}

server {
    listen 8080;

    location / {
        proxy_pass http://philter;
        proxy_next_upstream error http_502 http_503 http_504;
        proxy_read_timeout 300s;
        proxy_request_buffering off;
        client_max_body_size 0;
    }
}

Four settings matter for redaction traffic specifically:

proxy_next_upstream omits timeout. Philter sends nothing until redaction finishes, so a timeout does not mean the replica failed to receive the document; it may still be working on it. Retrying on timeout sends the same document to a second replica.

Redaction calls are POST /api/filter, and nginx does not pass a non-idempotent request to the next server once it has been sent. Failover therefore covers connection-level failures, and max_fails / fail_timeout take a failing replica out of rotation for subsequent requests. Adding non_idempotent to proxy_next_upstream would also retry after the body was sent, resubmitting the document; set it only if duplicate submission is acceptable.

proxy_read_timeout must cover the full processing time of the slowest document routed through it, the same sizing rule as the engine's readTimeoutMs. Set the balancer's value at or above the engine's, or the balancer will cut the connection first.

client_max_body_size 0 removes nginx's 1 MB upload cap, and proxy_request_buffering off streams large bodies through instead of spooling them to disk.

For active health checking rather than nginx's passive max_fails, Philter exposes GET /api/health. HAProxy checks it directly:

backend philter
    option httpchk GET /api/health
    server philter-a philter-a:8080 check inter 10s
    server philter-b philter-b:8080 check inter 10s

Two related cases:

Connection-level failover without a balancer. If the engine hostname resolves to several addresses (DNS round robin, a Kubernetes Service, Compose DNS), the router's HTTP client tries the next address when a connection fails. That handles a replica that is down or unreachable, but nothing for one that is up and returning errors or hanging, which is the case a health-checked balancer covers.

Separate named engines are not a pool. Define distinct engines when they differ in capability or policy, such as a Java engine and a .NET engine for docx/xlsx/OCR, and route to them by file attributes. Interchangeable replicas of the same engine belong behind one balancer under one name.

If the balancer terminates TLS with a self-signed certificate, caCertPath and insecureSkipVerify apply to the balancer's certificate rather than the replicas'. See Configuration.