Skip to content

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:

pip install casty[cert]

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

cd examples/14_tls_cluster
docker compose up --build

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

  1. casty cert create-ca generates an ECDSA P-256 CA certificate
  2. casty cert create-node generates a node certificate signed by that CA, with SANs for each address
  3. Config.from_paths loads the PEM files into ssl.SSLContext objects — one for server (inbound), one for client (outbound)
  4. Every TCP connection the cluster opens performs a mutual TLS handshake: both sides present and verify certificates
  5. 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.