TLS Cluster¶
In this guide you'll deploy a 5-node Casty cluster with mutual TLS using Docker Compose. You'll generate certificates with the built-in casty cert CLI, wire them into each node, and verify that all inter-node traffic — gossip, heartbeats, shard routing — flows over encrypted, authenticated connections.
The end result: a sharded counter incremented 50 times on the seed node, read back as RESULT=50 from all 5 nodes.
Prerequisites¶
Install the cert extra for certificate generation:
Generating Certificates¶
The gen-certs.sh script wraps three casty cert commands — one CA, five nodes:
#!/usr/bin/env bash
set -euo pipefail
DIR="${1:-/certs}"
mkdir -p "$DIR"
echo "Creating CA..."
casty cert create-ca --out "$DIR" --force
for i in 1 2 3 4 5; do
echo "Creating cert for node-$i..."
casty cert create-node "node-$i" localhost 127.0.0.1 \
--ca-dir "$DIR" --out "$DIR/node-$i" --force
done
echo "Done. Certificates in $DIR:"
find "$DIR" -name '*.crt' -o -name '*.key' | sort
Each node certificate includes node-{N}, localhost, and 127.0.0.1 as Subject Alternative Names (SANs). The SAN must match the hostname other nodes use to connect.
After running, the directory looks like:
certs/
├── ca.crt
├── ca.key
├── node-1/
│ ├── node.crt
│ └── node.key
├── node-2/
│ ├── node.crt
│ └── node.key
└── ...
Messages and Entity¶
The counter protocol is two frozen dataclasses — Increment and GetValue:
@dataclass(frozen=True)
class Increment:
amount: int
@dataclass(frozen=True)
class GetValue:
reply_to: ActorRef[int]
type CounterMsg = Increment | GetValue
def counter_entity(entity_id: str) -> Behavior[CounterMsg]:
def active(value: int = 0) -> Behavior[CounterMsg]:
async def receive(_ctx: Any, msg: CounterMsg) -> Behavior[CounterMsg]:
match msg:
case Increment(amount=amount):
return active(value + amount)
case GetValue(reply_to=reply_to):
reply_to.tell(value)
return Behaviors.same()
return Behaviors.receive(receive)
return active()
State flows through behavior recursion: active(value + amount) returns a new behavior with the updated count.
Wiring TLS into the Node¶
Each node receives --certfile, --keyfile, and --cafile from Docker Compose. Config.from_paths builds the mTLS contexts:
tls_config = tls.Config.from_paths(
certfile=args.certfile, keyfile=args.keyfile, cafile=args.cafile
)
The ClusteredActorSystem receives the TLS config and encrypts all TCP connections — gossip, heartbeats, shard routing, and event replication:
async with ClusteredActorSystem.from_config(
config,
host=host,
port=port,
node_id=node_id,
seed_nodes=seed_nodes,
bind_host=bind_host,
tls=tls_config,
) as system:
proxy = system.spawn(
Behaviors.sharded(entity_factory=counter_entity, num_shards=20),
"counters",
)
await system.wait_for(num_nodes)
log.info("Cluster ready (%d nodes)", num_nodes)
Docker Compose¶
The Dockerfile installs casty[cert] and generates certificates during build:
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY . /app
WORKDIR /app
ENV SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0
RUN uv sync --no-dev --extra cert
RUN uv run bash examples/14_tls_cluster/gen-certs.sh /app/certs
ENTRYPOINT ["/app/.venv/bin/python3", "examples/14_tls_cluster/main.py"]
Each node in docker-compose.yml gets its own cert/key pair, all signed by the same CA:
x-node: &node
build:
context: ../..
dockerfile: examples/14_tls_cluster/Dockerfile
services:
node-1:
<<: *node
hostname: node-1
command:
[
"--port", "25520",
"--host", "node-1",
"--bind-host", "0.0.0.0",
"--nodes", "5",
"--certfile", "certs/node-1/node.crt",
"--keyfile", "certs/node-1/node.key",
"--cafile", "certs/ca.crt",
]
node-2:
<<: *node
hostname: node-2
The remaining 4 nodes follow the same pattern, adding --seed node-1:25520 to join the cluster.
Running¶
You'll see each node start with TLS, form the cluster, and converge on RESULT=50:
node-1 | 12:00:01 INFO node-1 Starting (TLS enabled)...
node-2 | 12:00:01 INFO node-2 Starting (TLS enabled)...
...
node-1 | 12:00:05 INFO node-1 Cluster ready (5 nodes)
node-1 | 12:00:05 INFO node-1 Incrementing counter 50 times...
...
node-1 | 12:00:07 INFO node-1 RESULT=50
node-3 | 12:00:07 INFO node-3 RESULT=50
A node presenting an invalid or missing certificate is rejected at the TCP level — it never reaches the actor system.
What's Happening Under the Hood¶
casty cert create-cagenerates an ECDSA P-256 CA certificatecasty cert create-nodegenerates a node certificate signed by that CA, with SANs for each addressConfig.from_pathsloads the PEM files intossl.SSLContextobjects — one for server (inbound), one for client (outbound)- Every TCP connection the cluster opens performs a mutual TLS handshake: both sides present and verify certificates
- A node whose certificate wasn't signed by the shared CA is rejected immediately
See also: TLS reference for Config API details, custom SSLContext, and CLI command reference.