# When One Server Is Not Enough

A practical follow-up to the one-server setup: what I would do when a small app turns into a real business and the server starts running out of room.

Author: Hrvoje Pavlinovic

Published: 2026-07-18

Canonical: https://hrvoje.pavlinovic.com/blog/when-one-server-is-not-enough

Tags: Infrastructure, Scaling, Hetzner, PLAYGRND, Operations

Use this Markdown when pasting the article into Codex, Claude, ChatGPT, a gist, or a personal runbook.

This is a follow-up to [How I'd Run a One-Person Internet Company on One Server](https://hrvoje.pavlinovic.com/blog/one-man-company-server-setup).

The first article is about the setup I like before scale: one boring server, Cloudflare, Caddy, PostgreSQL, Redis, backups, GitHub Actions, and an AI agent that helps with operations. This one is about the next question: what happens if the boring setup works too well?

Imagine the small app becomes a real business. Traffic is no longer a few friends, a launch spike, or a local community. The app gets shared, organizers depend on it, search traffic starts compounding, bots show up, and the server is suddenly not just infrastructure. It is the thing standing between the business and users.

I am going to use PLAYGRND as the example, but keep it public-safe. No IPs, hostnames beyond public domains, secrets, private paths, or sensitive production details. The important parts are the shape: SvelteKit SSR, private Go API, PostgreSQL, Redis, Cloudflare, R2 assets, backups, GitHub Actions, and a product that is mostly public reads with a smaller set of important writes.

## The current shape is good for the first real stage

PLAYGRND is read-heavy. Most users are looking at matches, teams, players, standings, search results, and public football records. Writes matter, but they are not the dominant path: claims, corrections, organizer actions, WhatsApp login, admin review, ingestion, and background sync.

That matters because read-heavy apps can go much further on a simple setup than people think, especially when assets are on a CDN, expensive work is not done during the request, and public pages can be cached or precomputed.

```text
Cloudflare
  -> one Hetzner server
      -> Caddy
      -> SvelteKit SSR on loopback
      -> private Go API on Unix socket / loopback
      -> PostgreSQL on localhost
      -> Redis on localhost
      -> R2 for assets and database backups
```

This is still a good default. It is cheap, understandable, and fast because the app, API, database, and cache are close to each other. The biggest advantage is not just cost. It is operational clarity.

## What will break first?

I would not expect the first failure to be dramatic. It will probably be one of these:

- **Database queries get slow.** A few pages become expensive because they join too much data or miss the right index.
- **SSR becomes CPU-bound.** Anonymous public traffic hits dynamic rendering too often instead of cached pages or cheap API responses.
- **Bots waste origin capacity.** Crawlers and random probes can create more pressure than real users.
- **Search becomes its own workload.** Search feels small until it becomes a high-frequency entry point.
- **Uploads and image processing move into request time.** That should become async before traffic forces the issue.
- **Background jobs compete with web traffic.** Ingest, recompute, notifications, and AI workflows should not steal CPU from public reads.
- **Backups and restores become too slow.** The restore path matters more than the backup command.

The mistake is waiting until the whole server is overloaded. I would migrate when one part of the system is clearly becoming a different workload.

## Useful scaling math

I do not like pretending these numbers are precise. They are planning numbers, not promises. But they are useful because they show that DAU sounds bigger than the origin traffic often is.

```text
pageviews_per_day = DAU * pages_per_user_per_day
average_rps = pageviews_per_day / 86400
peak_rps = average_rps * peak_factor
origin_peak_rps = peak_rps * (1 - cache_hit_ratio)
```

For a public football record app, a reasonable first model is 5 pageviews per active user per day and a 10x peak factor. If public pages get a 90% edge/cache hit ratio, origin load changes completely.

ScenarioPageviews/dayAvg RPS10x peakOrigin at 90% cache10k DAU50k0.660.6 RPS100k DAU500k5.8586 RPS1M DAU5M5858058 RPS2M DAU10M1161,160116 RPS

This is why caching is not a polish step. It is architecture. Without caching, 1M DAU means hundreds of peak dynamic requests per second. With good caching, the same audience can look like tens of origin requests per second.

## What the current setup can probably handle

PLAYGRND's current server class is a 4 vCPU / 16 GB RAM style machine. Because the app is mostly public reads, assets live outside the box, and the API/database/cache are local, I would expect the current architecture to handle the early real-business stage comfortably if the hot pages stay cheap.

My rough, conservative planning range:

- **No serious HTML caching:** plan around 10k to 50k DAU before I would want dedicated capacity work, assuming 5 pageviews per DAU and normal peaks.
- **Good Cloudflare caching for anonymous public pages:** 100k DAU is realistic before infrastructure becomes the main problem.
- **Very cacheable public traffic:** 1M+ daily readers is possible only if most requests never hit origin, and if search, profile pages, and standings use cached or precomputed read models.

The real threshold is not DAU. It is sustained origin RPS, p95 latency, database time, queue backlog, and operational risk. I would start migration work before these become normal:

- Public SSR p95 over 300 ms for real users.
- Private API p95 over 50 ms on local hops.
- Postgres query p95 consistently above 20 to 30 ms for hot reads.
- Origin peak above 50 to 100 dynamic RPS without a clear cache plan.
- CPU above 60% for long periods during normal traffic.
- Redis memory or Postgres connections near limits.
- Backups/restores taking long enough that disaster recovery becomes scary.

Those are the alarms. Not the final outage.

## Phase 0: make the one server boringly efficient

Before adding servers, I would make the single-server version harder to waste.

- Put Cloudflare bot/rate rules in front of obvious abuse.
- Cache anonymous public pages where correctness allows it.
- Use Redis TTL caches for hot standings, player summaries, team pages, and search suggestions.
- Precompute expensive leaderboards and season aggregates.
- Add database indexes only from measured slow queries, not guesses.
- Move image work, notifications, AI, and ingest side effects out of request time.
- Keep write paths idempotent so retries do not duplicate product state.

For PLAYGRND, this means public reads stay fast and boring. Claims, corrections, WhatsApp messages, image uploads, and organizer actions can become async jobs. The user sees a fast response; workers do the slower work.

## Phase 1: split web/API from data

The first real migration should not be Kubernetes. It should be separation of roles.

```text
Cloudflare
  -> Load balancer
      -> app-1: SvelteKit SSR + Go API
      -> app-2: SvelteKit SSR + Go API

Private network
  -> postgres-1: primary database
  -> redis-1: cache, rate limits, lightweight queues
  -> worker-1: ingest, recompute, notifications, AI jobs

R2
  -> public assets
  -> database backups / snapshots
```

The app nodes become stateless. They do not own durable data. They can be replaced, rebuilt, or scaled horizontally. The database becomes its own machine with its own memory, disk, backup, and monitoring budget. Redis becomes a shared fast layer instead of a local implementation detail.

On Hetzner, this can stay simple: Cloudflare in front, Hetzner Load Balancer, a Hetzner private network, two app servers, one dedicated Postgres server, one Redis server, and workers on their own server. Hetzner's docs describe Load Balancers working with private networks and private target IPs, which is exactly the primitive I would want for this phase.

## Example load balancer shape

If I use a managed Hetzner Load Balancer, it routes public traffic to private app IPs and health-checks the app nodes. Caddy can still run on each app node as the local reverse proxy, or the node process can listen directly on a private port.

```text
Cloudflare DNS
  playgrnd.app -> load balancer public IP

Load balancer service
  listen: 443
  targets:
    10.0.1.11:3400
    10.0.1.12:3400
  health check: GET /status

Firewall
  public: only load balancer accepts web traffic
  app nodes: accept 3400 only from load balancer private IP
  database: accept 5432 only from app and worker private IPs
  redis: accept 6379 only from app and worker private IPs
```

If I wanted to self-manage the load balancer, Caddy can do upstream balancing too:

```caddy
playgrnd.app {
  encode zstd gzip

  reverse_proxy 10.0.1.11:3400 10.0.1.12:3400 {
    lb_policy least_conn
    health_uri /status
    health_interval 10s
    health_timeout 2s
  }
}
```

I would prefer the managed load balancer first. It removes one operational concern and gives a clear public entry point.

## Phase 2: make Postgres its own product

The database is the system of record. It should be the last thing to make clever and the first thing to protect.

The natural path is:

1. Dedicated Postgres host with enough RAM for hot indexes and working set.
2. pgBouncer or app-side connection discipline before app nodes multiply connections.
3. Slow-query logging and explicit indexes for hot public reads.
4. Read replicas only after the primary is healthy and query shape is known.
5. Point-in-time recovery and restore rehearsals before the business depends on the data.

For PLAYGRND, I would avoid sharding for a long time. Football records, teams, players, matches, seasons, claims, and corrections fit a relational model well. The first scale problem is likely read shape, not the total size of the database.

## Hetzner or AWS?

My default answer is: stay on Hetzner until the business risk is no longer traffic, but operations.

Hetzner still makes sense when:

- I want low fixed cost and direct control.
- The team is tiny and understands Linux.
- The app is mostly in Europe.
- One private network with app nodes, Postgres, Redis, and workers is enough.
- Manual recovery is acceptable if the runbooks and backups are tested.

AWS starts to make more sense when:

- Database failover must be managed and boring.
- Multi-AZ availability matters more than monthly cost.
- I want managed queues, secrets, IAM roles, metrics, alarms, and audit trails.
- I need autoscaling app nodes without maintaining every machine myself.
- Enterprise customers or payments make reliability a business requirement.

AWS RDS Multi-AZ is the big reason I would consider moving the database. AWS documents Multi-AZ deployments with standby instances for failover, and Multi-AZ DB clusters can also serve read traffic from readable standbys. That is a real operational upgrade compared with hand-rolling Postgres HA as a one-person company.

I would not put the app on Hetzner and the primary database on AWS unless there is a very specific reason. Cross-cloud database latency is an easy way to make every request worse. If the database moves to AWS, the app nodes should probably move near it too.

## The AWS version

If PLAYGRND outgrows the cheap VPS path and becomes a serious multi-million-user product, the AWS version would look like this:

```text
Cloudflare / WAF
  -> AWS Application Load Balancer
      -> app service: SvelteKit SSR + Go API containers or EC2 instances
      -> worker service: ingest, recompute, notifications, AI tasks

Private subnets
  -> RDS PostgreSQL Multi-AZ
  -> ElastiCache / Valkey or Redis
  -> SQS queues
  -> Secrets Manager / SSM Parameter Store

Object storage / CDN
  -> R2 can stay if it is working well
  -> or S3 + CloudFront if the whole stack moves to AWS
```

AWS Application Load Balancer target groups are designed to route to multiple targets with health checks. SQS standard queues give at-least-once delivery, which means every worker must be idempotent. EventBridge is useful later when product events need to fan out to multiple consumers, but I would not start there if a queue is enough.

## Redis, queues, and events

Redis is fine as the first shared cache and lightweight queue. The problem starts when Redis becomes the only record that something important happened.

The line I would draw:

- **Redis:** cache, rate limits, sessions, short-lived locks, hot counters, temporary queue state.
- **Postgres:** durable product state, claims, corrections, organizer actions, audit records, idempotency keys.
- **Queue:** work that can retry: image processing, notifications, AI extraction, recompute, sync, email, webhook follow-up.
- **Event bus:** later, when one product event needs several independent consumers.

For PLAYGRND, a good next step is an outbox pattern. The request writes essential product state and an outbox row in the same Postgres transaction. A worker reads the outbox and performs side effects. If the worker crashes, the event is still there. If the provider retries, idempotency keys prevent duplicate effects.

```sql
BEGIN;

INSERT INTO correction_requests (...)
VALUES (...)
RETURNING id;

INSERT INTO outbox_events (type, aggregate_id, payload, available_at)
VALUES ('correction.requested', $request_id, $payload, now());

COMMIT;
```

At small scale, one worker process can poll this table. At larger scale, the outbox publisher can push to Redis queues, SQS, or another broker. The product contract stays the same.

## Secrets at scale

On one server, server-only env files are fine. They are simple and understandable. At multiple hosts, secrets need a distribution model.

The rule stays the same: GitHub can know how to deploy, but it should not become the source of production runtime secrets.

- **Current stage:** env files on the server, restrictive permissions, GitHub only has deploy credentials.
- **Multi-Hetzner stage:** use a small secret manager, SOPS/age, 1Password, Doppler, Infisical, or another controlled source. Render env files during provisioning.
- **AWS stage:** use Secrets Manager or SSM Parameter Store, IAM roles for app access, and automatic rotation where it is worth the operational overhead.

AWS Secrets Manager supports secret rotation, including managed rotation for supported secrets. That becomes useful once database credentials and provider tokens are shared by several services.

## How I would migrate PLAYGRND

I would not do a big-bang migration. I would rehearse it in boring steps.

1. **Measure first:** origin RPS, SSR p95, API p95, slow queries, Postgres CPU/RAM/IO, Redis memory, queue backlog, error rate, cache hit ratio.
2. **Cache public reads:** add Cloudflare and Redis caching where correctness allows it.
3. **Create a private network:** app nodes, DB, Redis, and workers communicate privately.
4. **Add a second app node:** deploy the same release to both, put a load balancer in front, and keep sessions stateless.
5. **Move Postgres to a dedicated host:** restore from backup, rehearse migration, verify restore time.
6. **Introduce workers:** move image work, notifications, AI, recompute, and ingest side effects out of web requests.
7. **Add read replicas only when needed:** use them for public read models and reporting, not as a bandage for bad queries.
8. **Decide Hetzner vs AWS:** move to AWS when managed reliability is worth more than Hetzner's simplicity and cost.

## The cutover plan

For a database move, the safe plan is rehearsal, replication, short write freeze, cutover, and rollback window.

```text
1. Take a verified backup from the current primary.
2. Restore it into the new database host.
3. Run migrations and app smoke checks against the new database.
4. Start logical replication or rehearse a second restore delta if the DB is still small.
5. Put the old app into maintenance or read-only mode for writes.
6. Let replication catch up, or take a final dump if downtime is acceptable.
7. Switch app env to the new DATABASE_URL.
8. Restart app nodes behind the load balancer.
9. Smoke check public pages, login, claims, corrections, admin, workers, backups.
10. Keep the old system intact for rollback until the new one has survived real traffic.
```

For the app layer, the cutover is easier. Add a second node, run both behind the load balancer, and drain old traffic. For the database, patience matters more than speed.

## How it handles millions

Millions of users is not one problem. It is several smaller problems that need to stay separated.

LayerWhat changesWhyEdgeCloudflare cache, bot controls, WAF rules, image optimizationDo not send every anonymous read to originWeb/API2 to 6 stateless app nodes behind a load balancerScale request handling horizontallyDatabaseDedicated primary, read replicas, indexes, materialized read modelsProtect product state and cheapen hot readsRedisDedicated cache/rate-limit layerKeep hot data and abuse control outside PostgresWorkersSeparate worker pool and queuesKeep slow work away from public request latencyEventsOutbox first, queue/event bus laterMake side effects reliable and retryableSecretsSecret manager and per-service credentialsRemove manual env drift across hostsObservabilityMetrics, logs, traces, alerts, SLOsKnow what is failing before users tell you

At 1M DAU, I would want cache hit ratio above 90%, origin peak under control, app nodes stateless, database on its own host, Redis separated, and background jobs out of web requests. At 10M MAU with 10% DAU, the same architecture can still work if the public pages are cache-heavy. At 2M+ DAU or heavy write usage, I would expect to need managed database reliability, read replicas, better event infrastructure, and more serious observability.

## The real tradeoff

The one-server setup wins because it is simple. The multi-server setup wins because it isolates failure.

Every split buys something and costs something:

- Load balancer: buys horizontal app scale, costs another moving part.
- Dedicated database: buys data isolation, costs network hops and more backups/monitoring.
- Read replica: buys read capacity, costs replication lag and query routing decisions.
- Redis: buys cheap hot paths, costs cache invalidation and another failure mode.
- Queues: buy retryable async work, cost idempotency and operational visibility.
- AWS: buys managed reliability, costs money, complexity, IAM, and cloud-specific knowledge.

The right migration is not the fanciest architecture. It is the smallest architecture that removes the bottleneck without destroying the operational clarity that made the product possible in the first place.

## My current bet

If PLAYGRND grows quickly, I would not jump straight to AWS. I would first scale the Hetzner model into a small private network: load balancer, two app nodes, dedicated Postgres, dedicated Redis, and one worker node. That preserves the cost and simplicity advantage while removing the obvious single-server pressure.

I would move to AWS when the database and operations become the scary part: managed failover, multi-AZ durability, queues, secrets, IAM, alerts, and the ability to hire someone who already knows the platform. That is not a traffic milestone. It is a business-risk milestone.

The goal is not to prove that one server can last forever. The goal is to let one server carry the product until the business earns the right to need something bigger.

## References

- [Hetzner Load Balancer docs](https://docs.hetzner.com/networking/load-balancers/getting-started/creating-a-load-balancer/)
- [Hetzner private Networks docs](https://docs.hetzner.com/networking/networks/overview/)
- [AWS Application Load Balancer docs](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html)
- [Amazon RDS Multi-AZ docs](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZ.html)
- [Amazon SQS Standard Queue docs](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html)
- [AWS Secrets Manager rotation docs](https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html)
