What you'll be able to decide
Which way each connection should go: what gets a public endpoint, who comes in over a mesh network, and which service goes out through a tunnel; why a VPN or a bastion is rarely the answer now; and how to check that nothing else answers on a public address.
Private access is a question of direction. What the public must reach gets a public endpoint, on purpose, and only one per job. Your own machines (laptops, CI runners, agent boxes) come in over a mesh network that opens no port; a VPN or a bastion does the same job at more cost and with more to run. A service the public or a client's staff must see, on a box with no open port, goes out through a tunnel: the box calls out, nobody calls in. Decide the direction per connection, then list what still answers on a public address. Most boxes need a way in and a way out, and the two don't compete.
In short
The options
A. A public endpoint
Something listens on the internet, on purpose: the product's own API or site. Right for what the public must reach, and only one door per job.
B. A VPN or a bastion
AWS's own way to bring people into a VPC: an endpoint billed by the hour before anyone connects, with certificates and a directory to set up, or one more server to patch.
C. Coming in, over a mesh network
Your own machines join a private network that opens no port, and one policy file decides who reaches what. Any protocol: SSH, databases, internal services.
D. Going out, through a tunnel
The box opens a connection out to a public network, and visitors reach the service down it, behind a login if it isn't for the public. HTTP only, with no inbound port.
The rule of thumb
Tailscale is for my machines coming in; Tunnel is for a service going out to the public.
From Tailscale
How the answer changes with scale
One box: agents, previews, webhooks, an internal tool
Both, on the same box: services go out through a Tunnel, your machines come in over Tailscale, every app binds to 127.0.0.1, and no inbound port stays open except a locked-down SSH, or none.
A VPC with private databases, and a team that needs them
Two subnet routers in two zones, tagged so their keys don't expire, with split DNS to the VPC resolver. No bastion, no Client VPN, no public address on the database.
A client's staff who will never install an agent
An Access login in the browser, through a Tunnel: free up to 50 users, then $7 per user a month.
A public app with real traffic
A public endpoint, on purpose. Tunnel replicas give failover, not load balancing: a load balancer carries the load, or CloudFront in front of an internal one.
The problem, in plain words
Two kinds of people need to reach a system. The public, who use the product. And your own side: a developer running a query on the production database, a CI runner applying a migration, an agent reading a private service, a client reviewing a preview of their new site.
The old answer gives both the same kind of door: a public address, with a password, a security group full of home IP addresses, or a VPN gateway in front of it. Each of those doors answers on the internet, and a public IP on an always-on box is a target from day one.
So ask a different question first: which way does the connection go? Your own machines can come in, into a private network that never opens a port. A service that must be seen can go out: it opens a connection to a public network, and its visitors come back down that connection, so nothing on your side listens at all. Only what the public must reach needs a public endpoint, and then it should be the only one.
The picture
Four ways to reach a service. The first three are designs; the fourth is the trap inside the third.
A public endpoint
- Anyone on the internet
- A public address that answers
- The service
Right for what the public must reach. A public IP on an always-on box is a target from day one.
Coming in: a mesh
- Laptop
- CI runner
- Agent box
- Tailscale: the policy file decides
- Subnet router in the VPC
- Private database
No port is opened, and nothing in the VPC gets a public address.
Going out: a tunnel
- A visitor
- A client's staff
- Your domain on Cloudflare, Access login
- The connection the box opened
- Service on 127.0.0.1
The box calls out, nobody calls in.
A tunnel that closes nothing
- Anyone on the internet
- The box's public IP
- Service on 0.0.0.0, around Cloudflare and the login
The service still listens on every address: bind it to 127.0.0.1 and drop inbound traffic.
The options
A public endpoint, for what the public must reach. The product's own API has to answer on the internet, and that's fine: it's the job. A function URL is my favourite way to put Lambda on the internet, API Gateway comes in when you need what only it does at the door, and a load balancer in front of a Fargate service once the API is busy all day. Two rules come with it. One door per job: a public URL with auth NONE bypasses everything API Gateway enforces, so a function URL behind API Gateway gets IAM auth, or no URL at all. And nothing private gets one: function URLs are public endpoints only, so an API that must stay off the internet is API Gateway's REST private API. The public door doesn't even have to be yours. CloudFront's VPC origins reach an internal load balancer or instance, so nothing of yours faces the internet.
A VPN or a bastion: AWS's own way in. The classic way to bring people into a VPC. AWS Client VPN in Sydney is $0.15 per subnet association an hour (about $110 a month) plus $0.05 per connection-hour, and it comes with certificates and a directory or SAML to set up. A bastion is one more server to patch. Both are too expensive and too complicated for the job. Don't build either so a team can reach a database: a mesh network does it with no bastion to patch and no VPN endpoint billed by the hour. The same goes for two networks that must talk privately (a VPC and a VPS, two clouds): no site-to-site VPN needed.
Coming in: a mesh network. Tailscale builds a private network between your devices with WireGuard, and handles the hard parts: keys, NAT traversal, and who may reach what. It reaches a box behind any NAT without opening a port. Access is one policy file, in code, instead of security groups full of home IP addresses. Into AWS, one small subnet router in the VPC advertises its range, and a laptop, a CI runner or an agent box reaches a private Aurora endpoint as if it were local. Any protocol works: SSH, databases, internal services.
Coming in moves the risk from the internet to your own devices. Tailworm, in October 2026, showed what that means: from a device already authorised on the tailnet, Tailscale SSH and tailscale serve could lead to root on the servers that device was allowed to reach. Most of the findings needed an approved account or a compromised device in the tailnet, and none was a break-in from the internet. Tailscale shipped the fix, 1.104.4, about four days after the report, and subnet routers weren't in any finding. The lesson outlives the bug: give each device only what it needs, and keep the features layered on top of the network small.
Going out: an outbound tunnel. Cloudflare Tunnel turns it around: the box calls out, nobody calls in. cloudflared runs next to the service and opens outbound connections to Cloudflare, and a hostname on your domain routes down that connection to a port on localhost. No inbound port, no public IP to defend, no load balancer, and Cloudflare's TLS and DDoS protection in front. Put an Access login on anything that isn't for the public, and nobody reaches it without signing in. It's an HTTP proxy with Cloudflare's limits: 125 seconds to answer, 100 MB per request on Free and Pro, and several replicas give failover, not load balancing. For a single box, previews, internal tools and webhook receivers, that's exactly enough.
Inside AWS, neither. One service calling another in the same AWS network isn't anyone coming in or going out. Security groups and VPC endpoints do that job, not a mesh. Leave NACLs at their default and do the work in security groups, with rules that reference other security groups instead of IP ranges: NACLs are stateless and need the return traffic's ephemeral ports opened, so tightening one silently breaks responses.
The mesh and the tunnel don't compete. They point in opposite directions: one is the way in for your team, the other the way out for the public. There's nothing to choose between them inside one system. Most boxes I run use both.
A concrete scenario
A company in Sydney runs a booking product on AWS: an API on Lambda function URLs, Aurora in private subnets, a nightly import as a Fargate scheduled task, and one VPS for its agents and for client previews. Here's everyone who needs to reach something, and the door each one gets:
| Who connects | To what | Which way | The door |
|---|---|---|---|
| Customers | The API | Coming in, from the public | A public endpoint, on purpose: the function URL |
| Stripe | The app, with payment events | Neither | Straight to EventBridge in the company's account: no public webhook endpoint to write or secure |
| Developers, the CI runner, the agent box | Aurora | Coming in, from its own machines | Tailscale: two subnet routers in two zones, split DNS to the VPC resolver. No bastion, no Client VPN, no public address on the database |
| The client's staff, who won't install anything | A preview on the VPS | Going out | A Tunnel on a real hostname, behind an Access login, with the preview bound to 127.0.0.1 |
| The company's engineers | A shell on the VPS | Coming in | Tailscale, with the firewall dropping all other inbound traffic |
| The nightly import | Its container image and a partner's API | Out, and only out | A public subnet, a public IP and a security group that allows no inbound traffic. It has an address, and nothing answers on it. No NAT gateway, at about $43 a month before data, for every zone |
| The API's functions | Aurora | Inside AWS | Security groups that reference each other. Not Tailscale |
One door faces the internet, on purpose: the API. Every other connection is opened from the inside, or never crosses the internet at all. The two things I'd check first are the ones that quietly undo it: a preview still listening on 0.0.0.0, reachable around the tunnel and its login, and a database security group someone opened to a home IP address.
The trade-offs
| A public endpoint | A VPN or a bastion | A mesh (coming in) | A tunnel (going out) | |
|---|---|---|---|---|
| Who connects | Anyone | Your people, into the VPC | Devices on your tailnet, allowed by the policy file | Anyone, or only people who pass an Access login |
| Answers on a public address | Yes, on purpose | Its gateway does, by design | No: no port is opened | No: the box calls out |
| Fits | The product's own API and site | Rarely, now that a mesh does the job better | Your machines reaching private databases and services, any protocol | One HTTP service on a box: previews, internal tools, webhooks |
| Before anyone connects | A function URL is free; a load balancer has a monthly floor | About $110 a month per subnet association, or a server to run | Standard $8 per user a month | Tunnel free; Access free up to 50 users |
| The trap | A second door: a public URL with auth NONE bypasses API Gateway | More to run than the job needs | Device keys expire after 180 days, and an expired subnet router cuts everyone off | It closes nothing: a service on 0.0.0.0 is reachable around it. A stopped tunnel shows error 1016 |
The rule of thumb
Decide the direction first, then count the doors. Your own machines come in, over a mesh. A service the public or a client must see goes out, through a tunnel, unless it's the product itself at real volume, which gets a public endpoint on purpose. Services inside AWS talk through security groups. Then list everything that answers on a public address. Anything on that list that isn't meant for the public is a door to close.
When the answer changes with scale
- One box (agents, previews, webhooks): both, on the same box. Services go out through a Tunnel, your machines come in over Tailscale, every app binds to 127.0.0.1, and no inbound port stays open except a locked-down SSH, or none.
- A VPC with private databases: two subnet routers in two zones, tagged so their keys don't expire, with split DNS pointing at the VPC resolver. One router is one point of failure.
- A client's staff who'll never install an agent: an Access login in the browser, through a Tunnel: free up to 50 users, then $7 per user a month.
- A public app with real traffic: a public endpoint, on purpose. Tunnel replicas give failover, not load balancing, and Tailscale Funnel only serves *.ts.net names, on three ports, and is in beta. A load balancer carries the load, or CloudFront in front of an internal one.
- Client work: the client's own tailnet on a Standard plan, signed in with their identity provider. The free Personal plan is for non-commercial use.
Before you open a door
- Bind to 127.0.0.1, then drop inbound. A tunnel doesn't close anything: if the service still listens on 0.0.0.0, it's reachable on the box's public IP, around Cloudflare and around your Access login. Keep your own SSH path before you drop the rest.
- Tag the servers. Device keys expire after 180 days by default, and tagged devices have key expiry disabled by default. Make enrolment keys single-use or ephemeral: a captured reusable key lets an attacker enrol devices.
- Keep the layers on top small. Run Tailscale 1.104.4 or later on every node, set TS_SSH_DISABLE_FORWARDING=1 where you don't need forwarding, and use check mode and non-root users in the SSH rules. Four bulletins in one release tell you SSH and Serve are the layers to keep small. For a shell on an AWS instance, SSM needs no open port and is governed by IAM.
- Close the second door. A function URL behind API Gateway gets IAM auth, or no URL at all.
- Network access isn't AWS sign-in. Tailscale decides who can reach the network; Identity Center decides who can touch the account. A client gets both.
- Put it in code. The policy file, tags and auth keys in Pulumi next to the VPC; the tunnel, its hostname-to-service rules and its DNS records in the same program.
Questions that change the answer
Who connects: your own machines and tools, or the public and a client's staff? Your machines come in; for the public, a service goes out, or the product gets a public endpoint.
Will the people connecting install an agent? Staff who never will are better served by an Access login in a browser, through a Tunnel.
Is it HTTP? SSH and databases through a Tunnel need cloudflared on every client, so they belong on the mesh.
Must it answer on your own domain, at real volume? Tailscale Funnel serves *.ts.net names only, and tunnel replicas give failover, not load balancing.
Is it one service calling another inside AWS? Then it's neither: security groups and VPC endpoints do that job.
What answers on a public address today? Anything that isn't meant for the public is a door to close.
Doesn't cover
Who may sign in to the AWS account itself: that's AWS Organizations + IAM Identity Center. The network and the account are separate questions, and a client gets both.
Sign-in for your app's own users: Better Auth or Cognito?.
Rate limits, request validation and a WAF on a public endpoint: Function URL or API Gateway?.
Check your call
Decide before you open each answer.
A developer must run a query against a production Aurora database that has no public address.
Coming in: Tailscale. A subnet router in the VPC (two, in two zones), split DNS for the private names, and a policy rule that gives that developer that endpoint. No bastion, no Client VPN, and no public address on the database.
A client's marketing team must review a preview of their new site, on a box with no public port. They won't install anything.
Going out: a Cloudflare Tunnel on a real hostname, behind an Access login in the browser. Bind the preview to 127.0.0.1, so the tunnel is the only way to it.
An engineer wants to reach Postgres on that same box, through the tunnel that's already there.
That's coming in, not going out. A database through a tunnel needs cloudflared on every client: put the engineer's laptop on Tailscale instead.
Payment events from Stripe must reach an app that runs on AWS.
No public endpoint at all: Stripe delivers events straight to EventBridge in your account. On a box with no public port, a Tunnel receives the webhook instead.
An IT manager asks for AWS Client VPN so that five staff can reach an internal admin tool in the VPC.
First ask whether they'll install an agent. If they will, Tailscale; if they won't, a Tunnel with an Access login. Client VPN costs about $110 a month per subnet association before anyone connects, plus certificates and a directory or SAML to set up.