Skip to content

Scaling

The current architecture is designed as a single-replica deployment. This page explains the stateful components, their limitations, and the path to horizontal scaling.

Single-Replica Architecture

In the default setup, each service runs exactly one container. This is sufficient for most self-hosted use cases — a single instance can handle thousands of shortlink redirects and API calls per minute.

Shared vs. In-Memory State

Some components can use Redis (shared across replicas); others are still process-local. The selector is a single env var: set REDIS_URL and the components that support Redis switch to it automatically; leave it unset and everything stays in memory.

ComponentREDIS_URL unsetREDIS_URL set
Rate-limit countersin-memoryRedis (shared)
MCP session caps & presencein-memoryRedis (shared)
MCP idempotency cachein-memoryRedis (shared)
MCP live transport objectsin-memoryin-memory (always process-local)
Redirect slug cachein-memoryin-memory (process-local)

In-memory state does not survive restarts and is not shared across replicas. Note that the live MCP transport objects are always process-local — they hold open sockets and cannot be serialized to Redis. Redis makes the caps, presence counts, and idempotency global; serving a session's follow-up requests across replicas still requires sticky routing (see below).

Rate Limit Counters

Rate limit counters use the RateLimitStore interface. With REDIS_URL set, the server uses the Redis-backed store so counters are consistent across every replica; otherwise it uses the in-memory MemoryStore.

Impact of multiple replicas (in-memory): Each replica maintains independent counters, so a client routed across replicas can exceed the intended limit. Set REDIS_URL to fix this.

Impact of restart (in-memory): All counters reset to zero. Rate-limited clients can immediately resume.

MCP Sessions

The MCP server holds active Streamable HTTP transports in a per-process map and enforces idle-session reaping (MCP_SESSION_TTL_MS) and concurrent-session caps (MCP_MAX_SESSIONS, MCP_MAX_SESSIONS_PER_IDENTITY). With REDIS_URL set, session presence and the cap counts are tracked in Redis (keyed by identity, with a TTL refreshed on activity), so the global and per-identity caps are enforced consistently across replicas. The live transport object itself still lives only in the process that created it.

Impact of multiple replicas: Caps are global, but a transport is tied to the replica that created it. If a client's follow-up request is load-balanced to a different replica, that replica has no transport for the session. Multi-replica MCP therefore requires sticky routing (see below) — Redis makes the limits correct, not the transports portable.

Impact of restart: A restarted replica's live transports are gone (clients reconnect); their Redis presence keys expire on their TTL.

Idempotency Cache

The MCP idempotency cache deduplicates tool calls that carry an _meta.idempotencyKey. With REDIS_URL set it uses a Redis-backed storage adapter (with distributed locking); otherwise an in-memory adapter.

Impact of multiple replicas: With Redis, idempotency keys and their cached responses are shared across replicas, so a retried call that lands on a different replica is correctly deduplicated. Without Redis, the cache is per-process and a cross-replica retry could re-execute.

Impact of restart: With Redis, cached responses survive a single replica restarting (subject to their TTL); the in-memory adapter is cleared on restart.

Path to Multi-Replica

Enable Redis

Set one env var and the rate-limit counters, MCP session caps/presence, and MCP idempotency cache all switch to shared Redis-backed stores:

env
REDIS_URL=redis://redis:6379

Add a Redis service to your Compose file and set REDIS_URL on the server service. With it unset, all three stay in memory (single-replica).

Sticky Sessions for MCP

Redis makes the MCP caps and idempotency global, but live transports are still process-local, so multi-replica MCP also requires sticky routing — every request for a session must land on the replica that created it. Configure Caddy to route by the x-api-key header:

caddyfile
mcp.lnkify.io {
    reverse_proxy server1:4000 server2:4000 {
        lb_policy header x-api-key
    }
}

With Redis + sticky routing, GraphQL and redirects scale freely, MCP caps and idempotency are enforced globally, and each MCP session's traffic is pinned to its owning replica.

Status: Rate limiting, MCP session caps/presence, and MCP idempotency are all Redis-backed when REDIS_URL is set. The only remaining multi-replica requirement specific to MCP is sticky routing for the live transports.

Stateless Services

These services can scale horizontally immediately:

ServiceScaling Strategy
app (nginx)Trivially stateless. Run multiple replicas behind a load balancer.
docs (nginx)Trivially stateless. Run multiple replicas behind a load balancer.
caddyCaddy itself is stateless (certificates are in caddy_data volume). For multi-server setups, run Caddy on each node with shared certificate storage.
db (PostgreSQL)PostgreSQL supports multiple connections natively. For read-heavy workloads, add read replicas. For write-heavy workloads, consider connection pooling via PgBouncer.
redisUsed for shared rate limiting, MCP session caps, and idempotency when REDIS_URL is set. Can be clustered for high availability.

Caddy Load Balancing

If you run multiple server replicas, Caddy can load-balance across them:

caddyfile
lnkify.io {
    handle /graphql {
        reverse_proxy server1:4000 server2:4000
    }
}

Caddy uses round-robin by default. Other load-balancing policies are available:

caddyfile
reverse_proxy server1:4000 server2:4000 {
    lb_policy least_conn
}

Current Recommendation

For almost all self-hosted deployments, a single replica is the right choice. The architecture handles significant traffic with one instance, and the operational simplicity of single-replica is valuable. Consider scaling only when:

  • You're hitting rate limits on the single instance (not your own rate limits — resource limits like CPU/memory).
  • You need high availability (zero-downtime deployments, fault tolerance).
  • You have geographically distributed users who would benefit from regional instances.

Next: Upgrading

Released under the MIT License.