Skip to content

Deployment

This guide sets up a thread pipeline for production. Its Redis conversation memory persists across restarts and workers, encrypts every stored value, and reads its secrets from the environment. If you only need a single process that keeps nothing after it exits, the in-RAM memory is enough and you can skip to Conversational pipeline.

The pipeline reads its shape from a config file, so the deployment carries a TOML file plus a handful of environment variables. No pipeline code is written by hand.

Install the extras

The Redis memory pulls three extras beyond the config layer, plus one for the Argon2 hasher used below.

uv add "piighost[config,redis,crypto,argon2]"

The config extra reads the file, redis talks to the store, crypto provides the AES-GCM cipher, and argon2 provides the Argon2id hasher. Drop argon2 if you derive the keys with HMAC-SHA256 instead.

Write the config file

A [memory] section turns the pipeline into a thread pipeline keeping per-thread state. In this section, type = "redis" names the store. [memory.hasher] turns each message into its storage key, and [memory.cipher] encrypts each stored value.

[detector]
type = "regex"
catalogs = ["catalog:piighost/generic"]

[memory]
type = "redis"
url = "redis://redis.internal:6379/0"
namespace = "piighost"
ttl = 3600

[memory.hasher]
type = "argon2"

[memory.cipher]
type = "aesgcm"

namespace prefixes every key so piighost shares a Redis instance with other applications without collisions. ttl is the seconds a stored message lives before Redis evicts it. Omit it to keep entries until the store decides to drop them. The file declares no linker and no anonymizer, which keep their defaults. The default anonymizer emits <<PERSON:1>>, a token that carries identity, meaning it points to a single value. The middleware needs this identity to restore the value.

The full section catalogue, every component type, and the JSON form of the same file are in the configuration reference.

Set the secrets in the environment

The hasher pepper and the cipher key are secrets read from the environment at build time, never from the file. A file with a secret in it would leak the secret through version control.

export PIIGHOST_HASH_PEPPER="a-long-random-string"
export PIIGHOST_CIPHER_KEY="$(openssl rand -base64 32)"

PIIGHOST_HASH_PEPPER is any non-empty string. PIIGHOST_CIPHER_KEY is base64 of 16, 24, or 32 bytes, so openssl rand -base64 32 gives an AES-256 key. If a moderation guard is configured, its MISTRAL_API_KEY follows the same rule and lives only in the environment.

Load and run

load_thread_pipeline reads the file, builds every component, and returns the thread pipeline. It raises ConfigError if the file declares no [memory], so a stateless config cannot be loaded here by mistake.

import asyncio

from piighost.config import load_thread_pipeline

pipeline = load_thread_pipeline("pipeline.toml")


async def main() -> None:
    result = await pipeline.anonymize(
        "Write to alice@corp.com from 10.0.0.7.", thread_id="user-42"
    )
    print(result.text)


asyncio.run(main())

The output should be:

Write to <<EMAIL:1>> from <<IPV4:1>>.

The thread_id scopes the conversation. The same value in a later message of user-42 keeps its token. A different thread_id never sees it, so two users stay isolated. Behind the scenes, the pipeline hashes the message into a Redis key and stores the detections encrypted. A leak of the Redis disk therefore reveals neither the message nor the confidential data.

Bound the in-memory store

The default memory, InMemoryConversationMemory, keeps every thread in a process-local dict. This dict is bounded to 10,000 threads and one day idle. A long-lived process that never calls forget_thread thus does not keep every value it saw. Adjust max_threads to cap how many threads are kept. Beyond it, the least recently used thread is evicted. Adjust ttl to expire a thread that many seconds after its last write. The expired thread is only dropped on the next access.

[memory]
type = "in_memory"
max_threads = 1000
ttl = 3600

For a durable or multi-worker deployment, use a persistent backend instead, and forget a thread with forget_thread when its conversation ends.

How the store protects the data

Two protections combine on every write, both keyed by a secret the store never holds.

  • The key is hashed. The hasher derives a digest of the message under the pepper. argon2 (Argon2id) is slow and memory-hard, meaning costly in memory. It is the right choice when the pepper itself might leak. sha256 (HMAC-SHA256) is fast and fits a busy hot path. Both are deterministic, so the same message always lands on the same key.
  • The value is encrypted. aesgcm (AES-GCM) encrypts the detections before they are written, with a fresh nonce per message. Decryption fails on an altered ciphertext, so tampering is detected.

The thread_id stays in the clear, as a key namespace. This is what lets a whole thread be enumerated and forgotten with forget_thread. The threat model and the backend comparison are in Security.

Use a SQL database instead

If your stack already runs PostgreSQL, type = "sqlalchemy" gives the same durable, multi-worker store over any async SQLAlchemy driver. Install piighost[config,sqlalchemy,crypto,argon2], and point the config at an environment variable for the URL so the password stays out of the file.

[memory]
type = "sqlalchemy"
url_env = "PIIGHOST_DATABASE_URL"

[memory.hasher]
type = "argon2"

[memory.cipher]
type = "aesgcm"
export PIIGHOST_DATABASE_URL="postgresql+asyncpg://user:pass@db.internal/piighost"

The URL must use an async driver (postgresql+asyncpg://..., sqlite+aiosqlite://...). Create the table once at startup with await pipeline.memory.create_schema(). The hasher and cipher protect the stored values exactly as they do for Redis.

Serve it over HTTP with piighost-api

If several applications share the pipeline, or one that is not written in Python needs it, serve the same file with piighost-api, the companion server. Its Docker image is ghcr.io/athroniaeth/piighost-api. A first server outside Docker is built step by step in API server.

services:
  piighost-api:
    image: ghcr.io/athroniaeth/piighost-api:latest
    ports:
      - "8000:8000"
    environment:
      - PIIGHOST_CONFIG=/app/pipeline.toml
      - API_KEY_DEFAULT=${API_KEY_DEFAULT}
      - SECRET_PEPPER=${SECRET_PEPPER}
      - PIIGHOST_HASH_PEPPER=${PIIGHOST_HASH_PEPPER}
      - PIIGHOST_CIPHER_KEY=${PIIGHOST_CIPHER_KEY}
      - EXTRA_PACKAGES=piighost[crypto]
    volumes:
      - ./pipeline.toml:/app/pipeline.toml
      - cache:/root/.cache
    depends_on:
      - redis

  redis:
    image: redis:7-alpine

volumes:
  cache:

The mounted pipeline.toml is the file above, with its url set to redis://redis:6379/0, the address of the redis service. API_KEY_DEFAULT holds a key printed by keyshield generate, and the server refuses to start without one. The image carries the Redis client and the Argon2 hasher, and EXTRA_PACKAGES adds the AES-GCM cipher. The cache volume keeps the catalog references pinned to a commit, the model weights and the packages of EXTRA_PACKAGES across container restarts.

The image reads these variables:

VariableDefaultEffect
PIIGHOST_CONFIG/app/pipeline.tomlThe config file or catalog reference to serve. The image ships a default config, which loads every regex group of the catalog. A mounted file or a catalog reference replaces it
API_HOST0.0.0.0Bind host
API_PORT8000Bind port
LOG_LEVELinfoLog level
EXTRA_PACKAGESemptyPackages installed with uv pip install at container start, such as piighost[gliner2] for a configuration that runs GLiNER2

To serve a catalog configuration instead of a file, set PIIGHOST_CONFIG to its reference and add the Redis memory with a PIIGHOST_MEMORY variable, as shown in Server CLI. Each container runs a single server process, so scale by adding containers on the same Redis memory. Every route, the proxies included, is listed in API endpoints.

See also