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.
| Component | REDIS_URL unset | REDIS_URL set |
|---|---|---|
| Rate-limit counters | in-memory | Redis (shared) |
| MCP session caps & presence | in-memory | Redis (shared) |
| MCP idempotency cache | in-memory | Redis (shared) |
| MCP live transport objects | in-memory | in-memory (always process-local) |
| Redirect slug cache | in-memory | in-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:
REDIS_URL=redis://redis:6379Add 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:
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_URLis 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:
| Service | Scaling Strategy |
|---|---|
| app (nginx) | Trivially stateless. Run multiple replicas behind a load balancer. |
| docs (nginx) | Trivially stateless. Run multiple replicas behind a load balancer. |
| caddy | Caddy 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. |
| redis | Used 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:
lnkify.io {
handle /graphql {
reverse_proxy server1:4000 server2:4000
}
}Caddy uses round-robin by default. Other load-balancing policies are available:
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