You have one app on one VPS. Someone in the team channel asks whether you should put a load balancer in front of it. The honest answer is usually no, but you do need a reverse proxy, and most people calling it a load balancer mean exactly that.
That confusion is where the whole topic gets foggy. Once the words are clean, the decision is short.

Reverse proxy vs load balancer — name it correctly first
A reverse proxy sits in front of one or more upstream apps, terminates TLS, routes by hostname or path, and forwards requests. A single upstream is fine. Caddy, Traefik, Nginx, and HAProxy can all play this role. So can managed offerings like Cloudflare Tunnel.
A load balancer distributes traffic across two or more upstream instances. Round-robin, least-conn, hash-by-IP, weighted shifting — those are load-balancer concerns. With a single upstream, "load balancing" is a no-op. The same binary may do both jobs, but the operational shape is different.
If you have one VPS running one app container or one process, you do not have a load-balancing problem. You have a TLS-termination-and-routing problem. That is a reverse proxy.
Naming it correctly clears 80 percent of the question. The remaining 20 percent is genuinely about distributing traffic.
When you actually need a public load balancer
A few concrete triggers move you from "reverse proxy" to "load balancer":
- More than one app instance behind the same hostname, on the same host or across hosts.
- Blue-green or canary deploys, where two versions exist at once and traffic shifts between them.
- Zero-downtime rolling restart on a single host. This is still doable with one proxy + multiple containers, but the proxy is now load balancing.
- A multi-host fleet sharing a single public hostname.
- Per-region or per-tenant routing rules that go beyond simple host matching.
If none of those apply, you are looking for a reverse proxy. Stop shopping for HAProxy.
There is also a softer trigger: someone on the team has heard "production needs a load balancer" enough times that they assume one VPS without an LB is unprofessional. It is not. Production needs reliable TLS, predictable routing, and a graceful deploy story. Distribution across multiple instances is a separate problem you take on when traffic, fault tolerance, or rollout strategy demands it.
The realistic options
Five tools dominate small-team conversations. Pick by what you already run, not by what looks shiniest.
Caddy
Zero-config TLS is the headline. Drop a Caddyfile, get automatic Let's Encrypt, done.
example.com {
reverse_proxy app:3000
}
Caddy does load balancing too, but its config story for dynamic upstreams is weak. If you need targets to come and go automatically based on container labels or service discovery, Caddy will fight you. As a single-upstream reverse proxy, it is the lowest-friction choice on the list.
Traefik
Traefik's whole reason for existing is dynamic config. You label containers, Traefik picks them up. ACME is built in.
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`example.com`)"
- "traefik.http.routers.app.tls.certresolver=le"
- "traefik.http.services.app.loadbalancer.server.port=3000"
For container-heavy stacks, this is the default. The cost is conceptual surface area: routers, services, middlewares, providers, entrypoints. New operators get lost. Once it clicks, deploys become "start the new container, stop the old one."
HAProxy
The original L4/L7 balancer. Rock-solid, fast, observable. Configuration is plain-text, statically loaded, not friendly to label-driven workflows.
If you already know HAProxy, it is excellent. If you do not, learning it just to balance two containers on one VPS is overkill. Skip until traffic or correctness demands it.
Nginx
Universally available, well-documented, and what every Stack Overflow answer assumes. Reverse proxy and load balancing both work fine:
upstream app {
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Dynamic upstream changes mean editing config and reloading, or paying for Nginx Plus. For a stable two-container blue-green, that is fine. For aggressive autoscaling, it is not.
Managed ingress
Cloudflare Tunnel, your cloud provider's LB, Fly's edge, Railway's router. These hide the entire problem until traffic justifies owning it. The tradeoff is vendor lock-in and a bill that grows with traffic.
For early-stage teams on one VPS, a managed front-end plus a local reverse proxy is often the lowest-drama option. You get TLS, DDoS absorption, and a public IP that does not depend on your VPS reboot schedule. The "self-host vs managed" axis is not all-or-nothing: most healthy small-team setups put a managed edge in front and a local proxy behind it.
No-downtime instance switching on one VPS
Most "do I need a load balancer" questions are really "how do I deploy without dropping requests." On a single VPS, you do not need a multi-host LB. You need two app containers and a proxy that can hot-swap between them.
The pattern, in order:
- Old container
app-v1is running on port 3001 and live. - Start
app-v2on port 3002. Wait for its healthcheck to pass. - Tell the proxy to send new traffic to
app-v2. - Wait for in-flight requests on
app-v1to drain. - Stop
app-v1.
In Traefik, the swap is a label change plus a docker compose up -d of the new container. Old and new can coexist behind the same router using weighted services:
http:
services:
app:
weighted:
services:
- name: app-v1
weight: 0
- name: app-v2
weight: 100
Flip the weights, reload Traefik's file provider, done.
In Nginx, you keep both upstreams in the pool and mark the old one down once the new one is healthy:
upstream app {
server 127.0.0.1:3001 down;
server 127.0.0.1:3002;
}
nginx -s reload re-reads the file without dropping connections. This is plain reverse-proxy mechanics with two upstreams — call it load balancing if you want, but the operational shape is the same.
The decision framework
Ignore the search results. The flow is short:
- One VPS, one app, no rollout pain → use Caddy or Traefik as a reverse proxy. Stop reading.
- One VPS, one app, you want zero-downtime deploys → run two app containers, put a proxy in front, hot-swap upstreams. Traefik with labels or Nginx with
upstream+ reload both work. - Multiple VPSes, one public hostname → you are past "self-host the LB" territory. Use a managed LB (Cloudflare, your cloud's L4/L7 balancer) or commit to running HAProxy properly with health checks, observability, and config management.
- Container-heavy, frequent changes, no managed budget → Traefik. Its dynamic config is the whole point.
- Already running Nginx, low change rate → keep Nginx. Switching for ergonomics is not worth a migration.
- TLS feels scary → Caddy. Automatic certs remove a category of incidents.
If the answer requires a 30-minute discussion, default to "managed ingress in front, simple reverse proxy on the VPS." That setup is hard to mess up and easy to outgrow gracefully.
Common traps
A few patterns repeatedly turn small-team self-hosted balancing into outages.
Config drift between deploys. Hand-edited nginx.conf on the VPS does not survive a host rebuild. Keep proxy config in the repo or in a config-management tool. The proxy is part of the application, not part of the server.
Certificate rotation breakage. A reverse proxy that does not reload after certbot renew will serve an expired cert two months later. Wire the reload into the renewal hook. Verify with openssl s_client -connect host:443 against the live socket, not the file on disk.
Sticky sessions assumed but not configured. If your app stores session state in memory, a round-robin balancer will scatter users across instances and log them out at random. Either configure the proxy for cookie-based stickiness or move sessions to Redis. Picking neither is the failure mode.
Healthchecks that pass while the app is broken. A / route that returns 200 because the framework is alive tells you nothing about the database connection or downstream API. Add a real /healthz that exercises the dependencies the request path needs, and point the proxy's healthcheck at it.
No graceful drain on shutdown. When you stop app-v1, in-flight requests die. The fix is application-level: handle SIGTERM, stop accepting new connections, finish what you have, then exit. Without it, "zero-downtime deploy" still drops a handful of requests every time.
Two systems managing one cert lifecycle. If Traefik handles ACME and Certbot also runs on the host for the same domain, debugging gets opaque fast. Pick one source of truth and disable the other.
The shortest-path answer for most early-stage teams: put a reverse proxy on the VPS, terminate TLS there, run one or two app containers behind it, and only call it a load balancer when there are actually multiple instances to balance across. If you outgrow that, you will know — the symptoms are loud, and by then you will have the context to choose HAProxy, a managed LB, or a multi-host setup with eyes open.
Related in the StoicSoft network
If you're choosing a VPS provider or benchmarking real-world performance like the post above explores, StoicVPS is the StoicSoft network's independent tracker for VPS pricing, performance, and migration safety.
