← Back to essays
16h ago11 min read30 viewsExport Markdown

How Far Can One Hetzner Server Go?

by Hrvoje Pavlinovic

InfrastructureScalingHetznerCloudflareOperations

This is the third piece in the one-person infrastructure series. First I wrote about the one-server setup. Then I wrote about what happens when one server is not enough. This one is the capacity version: how far can the current Hetzner shape realistically go, what would break first, and what would I do on the path to 10 million users?

I did not point a load tester at production. That would be irresponsible for a live server that is already running real apps. This is a modeled capacity test: the kind of exercise I would do before running a proper staging load test. The numbers are assumptions, but the failure modes are real.

The setup I have in mind is the same boring one I like for the first serious stage:

Cloudflare
  -> Hetzner server, roughly the 4 vCPU / 16 GB class
      -> Caddy
      -> SvelteKit / Node SSR apps
      -> private API services
      -> PostgreSQL
      -> Redis
      -> R2 for assets and backups

The current bill is roughly 30-something euros per month for the main box, plus small extras. New Hetzner orders and rescales can be more expensive after the 2026 price changes, so I treat that number as the current operating cost, not a universal market price forever.

The fake test

I would model the test around a read-heavy public app. PLAYGRND is a good mental model: football pages, player profiles, match pages, standings, search, public records, and a smaller set of writes such as claims, corrections, admin work, media, ingestion, and notifications.

The first test matrix:

  • Pages per DAU: 5 public pageviews per active user per day.
  • Peak factor: 10x average traffic during launches, weekends, or viral spikes.
  • Dynamic response budget: public SSR p95 under 300 ms, API p95 under 50 ms on private hops.
  • Cache scenarios: 0%, 90%, 95%, and 98% edge/cache hit ratio for anonymous public traffic.
  • Write ratio: 1 meaningful write for every 50 to 200 reads.
  • Assets: served from R2/Cloudflare, not from the origin server.

The useful formula is simple:

pageviews_per_day = DAU * 5
average_rps = pageviews_per_day / 86400
peak_rps = average_rps * 10
origin_peak_rps = peak_rps * (1 - cache_hit_ratio)
AudiencePageviews/dayPeak edge RPSOrigin at 90% cacheOrigin at 98% cache
10k DAU50k60.6 RPS0.1 RPS
100k DAU500k586 RPS1 RPS
1M DAU5M58058 RPS12 RPS
10M DAU50M5,790579 RPS116 RPS

This table is the whole story. A 10 million user product can be impossible or surprisingly cheap depending on whether the origin sees 5,790 dynamic requests per second or 116.

Caching is not a performance trick. It is the business model for staying efficient.

What breaks first?

Without good caching, the first thing I expect to break is CPU on the SSR/web layer. Node SSR is useful, but dynamic rendering anonymous pages over and over is a very expensive way to show mostly stable public data.

The failure sequence would probably look like this:

  1. SSR CPU gets hot. The server spends too much time rendering pages that should have been cached, precomputed, or served as cheap JSON.
  2. API latency follows. The private API is not necessarily slow, but it starts waiting behind saturated CPU, connection pools, and shared machine pressure.
  3. Postgres connections pile up. Slow pages hold connections longer. More app concurrency makes the database look worse than it is.
  4. Hot queries become visible. Missing indexes, large joins, search, standings, and profile aggregation start dominating p95 latency.
  5. Memory becomes uneven. Node heap, Redis, Postgres buffers, and the OS page cache compete. Swap activity is the warning light.
  6. Logs and disk become boring but dangerous. Error storms and verbose access logs can turn a traffic issue into a storage issue.

My rough single-server limits, assuming the app is reasonably written but not heavily cached:

Origin dynamic loadExpected stateLikely bottleneck
10-50 RPSComfortable if queries are indexedUsually none
50-100 RPSStill workable, needs good p95 watchingSSR CPU or hot SQL
100-250 RPSDanger zone for one mixed-use boxCPU, DB connections, Node memory
250-500 RPSI would not want this on the single boxApp saturation and Postgres pressure
500+ RPSWrong architecture unless almost everything is cachedEverything competes with everything

With strong Cloudflare caching, Redis hot-path caching, and precomputed public read models, the same server can survive much larger audiences because the origin only sees misses, logged-in work, writes, admin, ingest, and cache refreshes.

The metrics I would watch

I would not scale from vibes. I would scale from a small set of boring metrics.

  • Edge traffic: requests, cache hit ratio, bot traffic, 4xx/5xx, top paths.
  • Origin traffic: dynamic RPS, p50/p95/p99 latency, error rate, slow routes.
  • CPU: sustained CPU above 60-70%, load average above available vCPUs, steal time if virtualized.
  • Memory: available memory, swap in/out, Node heap, Redis memory, Postgres shared buffers and cache hit ratio.
  • Postgres: slow queries, lock waits, connection count, transactions per second, index hit ratio, WAL growth, checkpoint pressure.
  • Redis: used memory, evictions, hit ratio, blocked clients, command latency.
  • Disk: free space below 25%, I/O wait, WAL volume, log growth, backup size and restore time.
  • Workers: queue depth, oldest job age, retry rate, dead-letter count.

The red lines I would use:

  • Public SSR p95 above 300 ms for more than a short spike.
  • API p95 above 50-100 ms for internal calls.
  • 5xx rate above 0.1% during normal traffic.
  • Postgres hot query p95 above 20-30 ms.
  • Database connections above 80% of the configured limit.
  • Redis evictions on keys that should not be disposable.
  • Swap activity increasing during normal traffic.
  • Disk above 75% or logs growing faster than cleanup policy.
  • Backup restore time longer than the recovery target I can live with.

If CPU falls first

If CPU is the first bottleneck, I do not immediately buy a bigger server. I first stop wasting CPU.

  1. Cache anonymous public pages at Cloudflare with explicit TTLs and purge rules.
  2. Move stable page fragments into Redis-backed read models.
  3. Precompute standings, top players, team summaries, season pages, and search suggestions.
  4. Make bots cheaper with Cloudflare rules, rate limits, and robots policy.
  5. Split web/app processes from background workers.
  6. Add a second app node behind a load balancer only after the hot paths are cheaper.

The important move is making app nodes stateless. Once a web node owns no durable state, adding a second one is straightforward.

If Postgres falls first

If Postgres is first, scaling app nodes makes the problem worse. The database needs respect.

  1. Turn on slow query logging and fix the top queries.
  2. Add indexes from measured query plans, not guessing.
  3. Add pgBouncer or strict app-side connection pools.
  4. Create precomputed read tables for public pages.
  5. Move Postgres to its own machine with fast NVMe and enough RAM for hot indexes.
  6. Add a read replica only after the primary is healthy.
  7. Keep writes on the primary and route only safe public reads to replicas.

I would avoid sharding for a long time. Sharding is not a prize. It is a tax you pay when a single relational database can no longer carry the shape of the product.

If memory falls first

Memory failure is usually a symptom of too many things living on one machine.

  1. Put systemd memory limits on noisy services.
  2. Cap Node heap where appropriate.
  3. Separate background workers from web traffic.
  4. Keep Redis bounded with explicit maxmemory policy and TTLs.
  5. Move Postgres to a dedicated host before it fights the web layer for page cache.
  6. Watch for leaks through RSS over time, not only heap snapshots.

If swap is rising under normal load, I treat that as an incident. The server may still look alive, but latency will already be lying.

If the API falls first

If the API is slow while CPU and Postgres look fine, the problem is usually concurrency, blocking work, or external calls.

  1. Make every external provider call asynchronous unless the user truly needs it in the response.
  2. Add timeouts and circuit breakers.
  3. Use idempotency keys for writes and retries.
  4. Move side effects into a queue.
  5. Separate API workers from web SSR if they have different resource profiles.
  6. Cache read responses with precise invalidation.

The API should mostly coordinate product state. It should not become the place where image processing, AI extraction, email, webhooks, and recompute all happen during user requests.

The architecture at 10 million users

For the best-case, efficiency-first version of 10 million users, I would choose Hetzner plus Cloudflare, not AWS.

That answer depends on the product shape. If the product is read-heavy, mostly European, cacheable, and operated by a tiny team that understands Linux, Hetzner keeps the cost ridiculously low. Cloudflare handles the edge. R2 handles assets and backups without egress fees. Hetzner handles compute and the database close to the app. AWS becomes tempting when managed failover, enterprise procurement, IAM depth, compliance, and a larger operations team matter more than raw efficiency.

The 10M version I would build:

Cloudflare
  - DNS, WAF, bot rules, cache rules, image/assets edge cache
  - public HTML cache for anonymous pages where correctness allows it
  - R2 for assets, exports, backups, and cold objects

Hetzner private network
  - managed Load Balancer
  - 4-8 stateless web/API nodes
  - 2 worker nodes for ingest, notifications, AI, media, recompute
  - dedicated PostgreSQL primary on fast NVMe
  - PostgreSQL replica for reads and backup rehearsals
  - dedicated Redis / Valkey node for cache, rate limits, hot counters
  - NATS JetStream or Postgres outbox publisher for durable async events
  - monitoring/logging node or managed external observability

Operational rules
  - no public database
  - no app state on web nodes
  - all writes idempotent
  - outbox table for durable product events
  - tested restore path before traffic makes it scary

The edge target is aggressive: 95-98% cache hit ratio for anonymous public pages and assets. At 10M DAU and 5 pageviews per day, that is the difference between 5,790 peak requests per second hitting origin and 116-290 origin RPS. The first number is a cloud bill. The second number is an engineering problem.

The 10M cost model

Current stage:

  • Server: about 30-something euros per month for the main Hetzner box.
  • Cloudflare: free or low-paid plan depending on how serious the zone is.
  • R2/backups: usually tiny at this stage.
  • Total: roughly EUR 30-60/month for the practical one-person setup.

10M best-case stage, still optimized for efficiency:

PartMonthly estimateWhy
Cloudflare Pro/BusinessUSD 25-250Cache, WAF, bot controls, operational confidence
Hetzner load balancingEUR 10-30Managed public entry point into private network
4-8 app/API nodesEUR 250-700Stateless web/API capacity with room for deploys
Postgres primary + replicaEUR 300-800Fast NVMe, RAM for indexes, safer maintenance
Redis, workers, queue/event nodesEUR 150-400Cache, async jobs, ingestion, media, AI work
R2 assets/backupsUSD 20-200Storage and operations; egress is the important zero
Monitoring/logging/backups bufferEUR 100-300Logs, alerts, restore rehearsals, extra storage

My best-case target would be around EUR 800-1,800/month for 10 million users, if the product remains cache-heavy and single-region. That is the number I would design toward. The bad version of the same product can easily be 10x more expensive if every page is dynamic, every asset misses cache, search hits Postgres directly, and background work competes with web traffic.

An AWS version would be operationally nicer in some ways, but the bill changes shape. RDS Multi-AZ, managed Redis, ALB, NAT/data transfer, CloudWatch logs, queues, snapshots, and support can push the same app into the low thousands to five figures per month much earlier. That can be worth it for a larger company. For a one-person company trying to stay efficient, I would earn that complexity later.

The migration path

I would not jump from one server to the final diagram. I would do it in this order:

  1. Make public reads cheap. Cloudflare cache rules, Redis read models, precomputed pages, bot controls.
  2. Separate workers. Move ingest, AI, media, notifications, and recompute out of request time.
  3. Add observability. Basic dashboards for edge traffic, origin RPS, p95, slow SQL, queues, memory, disk, backups.
  4. Add a load balancer and second app node. Prove stateless deploys and health checks.
  5. Move Postgres to its own machine. Rehearse backup/restore, then cut over carefully.
  6. Add a read replica. Use it for public read models, reporting, and restore confidence.
  7. Make events durable. Postgres outbox first, then NATS/SQS/EventBridge only when the product needs fan-out.
  8. Scale app nodes horizontally. Add capacity behind the load balancer, not by making one box magical.
  9. Revisit AWS. Move when managed reliability is worth more than Hetzner efficiency.

The main lesson

The server will not fail because 10 million users exist in a database. It will fail when too many uncached, expensive, stateful things happen at the same time.

The plan is therefore not just bigger machines. It is fewer origin requests, cheaper hot paths, durable async work, isolated database capacity, and boring recovery.

One server is a great starting point because it makes the system understandable. Scaling should preserve that clarity for as long as possible. The goal is not to prove that Hetzner can replace every cloud platform. The goal is to keep the company efficient until the product earns a more expensive architecture.

References