Horizontal Scaling

Run Alarik as a multi-node cluster with a shared control plane and sharded, replicated object data.

A single Alarik instance with its default SQLite database and local disk is a complete, self-contained deployment - nothing in this guide is required to get started. Horizontal scaling is opt-in and layered on top, in two independent tiers:

  1. Postgres mode - share the control plane (buckets, users, access keys, policies, webhook/replication config) across multiple stateless nodes behind a load balancer. Object data itself stays local to whichever node stores it.
  2. Cluster mode - built on top of Postgres mode - shard and replicate object data itself across nodes, so any node can serve any request regardless of which node originally stored the object.

Each tier is a strict opt-in on top of the one before it. A single Postgres-mode node with cluster mode off behaves exactly like single-node SQLite mode from a data-placement point of view - only the control plane moved.

Postgres Mode

By default, Alarik uses an embedded SQLite database - perfect for a single instance, but SQLite can't be safely shared by multiple processes over a network. Setting DATABASE_URL switches the control plane to Postgres, which every node in the deployment points at:

DATABASE_URL
string
A Postgres connection string. When set, Alarik uses Postgres instead of the embedded SQLite database for the control plane.
DATABASE_URL=postgres://user:password@host:5432/alarik

With Postgres mode on, every node keeps an in-memory cache of buckets, access keys, policies, and the rest of the control plane, invalidated in real time via Postgres LISTEN/NOTIFY - a change made through any node (a new bucket, a revoked access key, an updated policy) propagates to every other node within milliseconds, without polling.

Object data is unaffected by this tier alone. Each node still reads and writes object bytes on its own local disk - a PutObject that lands on node A is invisible to a GetObject that lands on node B, unless cluster mode (below) is also enabled. Postgres mode by itself is for control-plane HA behind a load balancer where you don't need any single node to see every other node's objects - for example, several independent single-tenant deployments sharing one admin/auth layer.

Point every node at the same Postgres database and put a load balancer in front of them; any node can handle any control-plane request (login, bucket management, policy changes, and so on).

Cluster Mode

Cluster mode shards and replicates object data itself across nodes using rendezvous (HRW) hashing - deterministic, coordination-free placement where adding or removing a node only moves the objects whose ownership set actually changed, not a large contiguous chunk of the keyspace.

Enabling it requires two additional variables, on top of DATABASE_URL:

CLUSTER_NODE_ADDRESS
string
This node's internally-reachable base URL - how other nodes reach it for forwarding and replication traffic. Not auto-detected; Docker/Kubernetes networking makes a reliable guess impractical, so this is operator-provided, the same way API_BASE_URL already is.
CLUSTER_NODE_ADDRESS=http://10.0.1.5:8080
CLUSTER_SECRET
string
A shared secret used to authenticate inter-node traffic (an X-Alarik-Cluster-Secret header, checked in constant time) - every node in the cluster must use the same value. This is separate from client-facing SigV4/JWT auth: by the time a request reaches a peer node it's already been authenticated once at the entry node.
CLUSTER_SECRET=YOUR_SECURE_CLUSTER_SECRET
Both variables must be set together, and DATABASE_URL must already be set - cluster mode requires the shared Postgres control plane. A node started with only one of CLUSTER_NODE_ADDRESS/CLUSTER_SECRET, or without DATABASE_URL, fails to start with a clear error rather than booting half-configured.
CLUSTER_MIN_FREE_PERCENT
string
The percentage of free disk space, below which a node is considered "near-full" for new-write coordination (see Capacity-aware placement). Defaults to 10.
CLUSTER_MIN_FREE_PERCENT=10
Inter-node traffic is authenticated (shared secret) but not encrypted. Run cluster traffic on a private network, the same assumption most Postgres/Redis clustering setups make - CLUSTER_SECRET is a trust boundary, not a substitute for network isolation.

How object placement works

Every object's key is hashed together with each active node's identity to deterministically rank every node for that key. The top 3 (the replication factor, fixed cluster-wide - not configurable per bucket) become that object's responsible nodes; the highest-ranked of the three is its primary replica.

Any node can front any client request. The node a load balancer happens to hit authenticates the request exactly as it always does, then either serves it locally (if it's one of the object's responsible nodes) or forwards the already-authenticated request to a responsible peer, which replies as if it had received the request directly. From a client's perspective, every node in the cluster behaves identically.

Writes: quorum and catch-up

A write is coordinated by one of the object's responsible nodes (whichever one the request landed on or was forwarded to). It writes locally, then fans out to the other responsible nodes concurrently and waits for a majority to acknowledge (2 of 3, at the default replication factor) before responding to the client. A replica that doesn't ack in time - slow, or briefly down - still gets a durable, retried delivery task; the client was already told "success" based on quorum, so this is pure catch-up, not something the client waits on.

Reads fall back across every responsible node in order, so a client is only affected if every replica for a given object happens to be down at once.

Automatic rebalancing

An explicit membership change - a node joining, or an operator draining it - triggers an automatic rebalance walk on every remaining node. Each node reconciles what it physically holds against current placement:

  • Copy: objects it's still responsible for get pushed to any newly-responsible peer that may not have a copy yet.
  • Reclaim: objects it's no longer responsible for get deleted locally, but only once every new owner is confirmed to actually have a copy - never before. This includes historical object versions and delete markers, not just current objects.

This is fully automatic for ordinary, explicit membership changes - no manual trigger needed.

A node that crashes or is killed - as opposed to being explicitly drained - does not trigger a rebalance on its own. Its heartbeat simply stops; once that's gone stale, the rest of the cluster stops routing to it, but nothing automatically re-replicates the data it was holding, since no membership-change event ever fired. After confirming a node is genuinely gone for good, either drain it (to exclude it from placement and trigger the walk) or trigger a manual resync to restore full replication for whatever it was holding.

A manual resync is also the right call for a node that was down long enough to exceed the replication outbox's retry limit, past the point where ordinary catch-up delivery would ever revisit it.

Capacity-aware placement

Every node reports its own free disk space on its regular heartbeat. When a node's free space drops below CLUSTER_MIN_FREE_PERCENT, it's treated as "near-full" for new-write coordination only: if a near-full node would otherwise coordinate a write (because it's one of the object's responsible nodes), it instead hands coordination off to whichever other responsible node has the most free space.

This is a soft, deliberately narrow preference, not capacity-based rebalancing:

  • Placement itself (which nodes are responsible for a key) never changes because of capacity - only which of the already-responsible nodes happens to coordinate a given write.
  • A near-full node's existing data is never evicted or moved automatically. To free up space on a specific node, drain it as usual.
  • If every responsible node for a key is near-full, the write proceeds locally anyway - a write is never refused for capacity reasons.

Draining a node

To take a node out of service without losing data, drain it rather than just stopping the process. Draining excludes the node from new placement immediately and kicks off a rebalance walk to migrate its data onto the remaining nodes - watch rebalance status drain to zero pending/failed tasks before actually stopping it.

Listing and scanning

Because placement is per-key (not range-sharded), no single node knows in advance which keys under a prefix live where. Bucket-wide operations - ListObjectsV2, ListObjectVersions, ListMultipartUploads, and the check that a bucket is empty before DeleteBucket is allowed - transparently fan out to every active node and merge the results, so they behave identically whether the cluster has one node or ten.

DeleteBucket's empty-bucket check fails closed: if it can't reach every active node to confirm none of them hold an object for that bucket, the delete is refused rather than risking an orphaned object on an unreachable node.

Console

The admin console's Cluster page (Admin → Cluster) shows live node health, a storage distribution chart across nodes, pending/failed replication counts, and a placement browser for inspecting exactly which nodes hold a given bucket's objects. Drain and manual resync are available as actions from the same page. See the admin cluster API reference to drive the same operations programmatically.

The Dashboard page is different in kind: its process metrics (CPU, memory, traffic, local disk) describe only the node that happened to answer the request, not the cluster as a whole - it's labeled with that node's address in cluster mode to make this explicit. For the aggregate picture across every node, use the Cluster page instead.

Example deployment

A minimal 3-node cluster sharing one Postgres instance:

docker-compose.cluster.yml
services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      - POSTGRES_USER=alarik
      - POSTGRES_PASSWORD=YOUR_SECURE_DB_PASSWORD
      - POSTGRES_DB=alarik
    volumes:
      - alarik-postgres:/var/lib/postgresql/data

  alarik-1:
    image: ghcr.io/achtungsoftware/alarik:latest
    restart: unless-stopped
    ports:
      - "8081:8080"
    environment:
      - API_BASE_URL=http://localhost:8081
      - JWT=YOUR_SECURE_JWT_KEY
      - DATABASE_URL=postgres://alarik:YOUR_SECURE_DB_PASSWORD@postgres:5432/alarik
      - CLUSTER_NODE_ADDRESS=http://alarik-1:8080
      - CLUSTER_SECRET=YOUR_SECURE_CLUSTER_SECRET
    volumes:
      - alarik-1-storage:/app/Storage
    depends_on:
      - postgres

  alarik-2:
    image: ghcr.io/achtungsoftware/alarik:latest
    restart: unless-stopped
    ports:
      - "8082:8080"
    environment:
      - API_BASE_URL=http://localhost:8082
      - JWT=YOUR_SECURE_JWT_KEY
      - DATABASE_URL=postgres://alarik:YOUR_SECURE_DB_PASSWORD@postgres:5432/alarik
      - CLUSTER_NODE_ADDRESS=http://alarik-2:8080
      - CLUSTER_SECRET=YOUR_SECURE_CLUSTER_SECRET
    volumes:
      - alarik-2-storage:/app/Storage
    depends_on:
      - postgres

  alarik-3:
    image: ghcr.io/achtungsoftware/alarik:latest
    restart: unless-stopped
    ports:
      - "8083:8080"
    environment:
      - API_BASE_URL=http://localhost:8083
      - JWT=YOUR_SECURE_JWT_KEY
      - DATABASE_URL=postgres://alarik:YOUR_SECURE_DB_PASSWORD@postgres:5432/alarik
      - CLUSTER_NODE_ADDRESS=http://alarik-3:8080
      - CLUSTER_SECRET=YOUR_SECURE_CLUSTER_SECRET
    volumes:
      - alarik-3-storage:/app/Storage
    depends_on:
      - postgres

volumes:
  alarik-postgres:
  alarik-1-storage:
  alarik-2-storage:
  alarik-3-storage:

Point a load balancer (or your S3 client, for local testing) at any of the three nodes - there's no "primary" to pick, any node handles any request.

Scope and limitations

  • The replication factor is a fixed cluster-wide constant (3), not configurable per bucket.
  • Placement has no rack/zone awareness - it treats every node as equally likely to fail independently.
  • Inter-node traffic isn't encrypted; run it on a private network.
  • In-progress multipart uploads aren't replicated while the upload is happening - only the final completed object goes through quorum replication. A node holding an in-progress upload that crashes mid-upload loses that upload; the client needs to restart it.
  • A crashed (not explicitly drained) node doesn't trigger automatic rebalancing - see Automatic rebalancing above. An operator needs to drain it or trigger a manual resync once it's confirmed gone.
  • Capacity awareness only ever redirects new-write coordination among a key's already-responsible nodes - see Capacity-aware placement above. It doesn't rebalance data off a full node, and doesn't help if the whole cluster is near-full, not just one node.