You start your startup with one backend service. Simple, lean, single VPS. Works great. But as your product grows, you add more: a caching layer to speed up reads, a message queue to handle async jobs, maybe a microservice to offload payments or image processing. Suddenly you’re running five services on one box.
Each new service gets treated the same way in most tutorials: spin it up, let it listen on 0.0.0.0, point a reverse proxy at it, done. Repeat for the next service.
Within minutes of launch, you’re getting port scans. Exploit attempts. Bots crawling for management consoles, admin panels, unprotected endpoints. A standard reverse proxy will tell them “yes, that admin panel exists, you’re just not allowed in” via a 403 response. That’s a green flag to an attacker.
So the obvious question: why expose any of these internal services to the internet at all? Your caching layer doesn’t need public access. Your message queue doesn’t. Your microservices don’t. Only your frontend should face users.
The Pattern#
You have a backend service that only your frontend needs to talk to. Database, cache, internal API, message queue, whatever. It doesn’t need public access. The internet doesn’t need to find it.
The pattern is simple:
Decouple the service from the host network. Don’t map it to
0.0.0.0:6379. Run it on an internal bridge with no public interfaces. Use Docker Compose, systemd, whatever—just make sure the service can only be reached from localhost or a private network.Sit behind a programmable edge gateway. OpenResty, Nginx with Lua, or custom reverse proxy. Single point of validation. The gateway is what faces the internet; everything else (backend, database, cache, queues, internal APIs) hides behind it on the private network completely.
Validate at the gateway. Check headers. Verify domain. Rate limit. IP whitelist admin access. All at the network edge, before requests reach your backend.
Lock down host SSH. Because your VPS is the last place an attacker can reach. Use post-quantum hybrid ciphers if you can. Disable password auth. Lock it down tight.
The benefit? Your entire backend infrastructure—services, databases, caches, queues—doesn’t announce itself. Scanners hit the gateway, not your infrastructure. They can’t even tell what’s running behind it. Rate limiters kick in. Admin access requires a whitelisted IP. It looks like there’s nothing there to attack.
Is it bulletproof? No. But it raises the cost of entry massively. Most attackers move on.
In Practice#
Your database listens on 5432, but only from inside the container. Your Redis cache has weak auth, you never intended for it to face the internet. Your background job worker handles webhooks and orchestrates other services—also internal. Your admin dashboard needs access, but not on its own exposed port; it goes through the gateway on 443 with IP whitelisting. None of these expose themselves directly.
What changes? Everything hides behind the gateway. Frontend requests hit the gateway first. The gateway validates them (auth headers, rate limits, IP checks), then forwards to your backend on the private network. Your backend talks to the database, cache, queues, and internal services. Scanners can’t port-scan your database on 5432. They hit the gateway, not your infrastructure. Your admin dashboard isn’t exposed on a public port; requests go through the gateway on secure port 443 (https), IP whitelisted. Your background workers process jobs safely on an internal network.
The payoff: your internal services don’t leak their existence. An attacker sees the gateway. That’s it. They can’t fingerprint what’s running behind it, can’t probe for weak auth on your cache. Your admin dashboard sits behind the gateway with IP whitelisting—even if they find the /admin URL, their request hits the gateway, fails the IP check, gets dropped. No 403 “access denied” that leaks the endpoint exists. Most move on.
The Architecture#
graph LR
A["Internet / Attacker"] -->|Requests| B["Gateway
Public-facing
Validates, rate limits
TLS termination"]
B -->|Internal only| C["Private Network"]
C --> D["Backend
Service"]
D --> E["PostgreSQL
Database"]
D --> F["Redis
Cache"]
D --> G["Message
Queue"]
D --> H["Internal
APIs"]
style A fill:#ffcccc
style B fill:#ffffcc
style C fill:#ccffcc
style D fill:#ccffcc
style E fill:#ccffcc
style F fill:#ccffcc
style G fill:#ccffcc
style H fill:#ccffcc
Only the gateway sees the internet. Everything behind it stays invisible.
Why This Matters#
On a bootstrapped VPS, you’re trading off operational simplicity for security. A managed cloud service abstracts this away—AWS, GCP, Azure all have built-in firewalls, DDoS protection, managed services that don’t expose anything by default. You pay for it.
A cheap VPS gives you a clean slate and nothing else. You have to build your own protections. Decoupling your internal services from public access is the foundational move. Everything else—rate limiting, IP whitelisting, SSH hardening flows from there.
It’s not complicated. It’s just a different mental model: the internet should not be able to reach your core infrastructure at all. Everything goes through a gate. The gate does the validation. Core systems stay invisible.
Going Deeper#
This pattern works at any scale. Single VPS running everything in Docker Compose. Kubernetes cluster with internal services. Multi-region setup. The principle stays the same: decouple, shield, validate at the edge.
If you want to see this in action, we built it for a PocketBase based startup stack. Read the full technical walkthrough here. That article covers the specific implementation—Docker Compose setup, OpenResty Lua configuration, Ansible orchestration, post-quantum SSH hardening. But the core pattern is what you just read.
Try it on your next service that doesn’t need public access. You’ll be amazed how quiet your infrastructure becomes.