Horizontal Scaling
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:
- 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.
- 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=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.
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:
API_BASE_URL already is.CLUSTER_NODE_ADDRESS=http://10.0.1.5:8080
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
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.10.CLUSTER_MIN_FREE_PERCENT=10
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.
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 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:
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.