Skip to main content
Most of the effort around a website goes into making it reachable. Sometimes the requirement is the opposite one: the application has to run, be deployed, be maintained, and answer nobody who finds its address from the outside. That comes up more often than it sounds. An internal admin panel or back office has no reason to accept traffic from anywhere but the office network. An origin behind a CDN should answer the CDN and nothing else, otherwise anyone who learns the origin address walks around the edge rules you pay for. A B2B API whose only clients are three partner systems doesn’t need a public front door. Neither does a webhook receiver that only ever hears from one SaaS provider, a headless CMS backend that only a build job queries, a preview environment for a product that hasn’t launched, an internal metrics dashboard, or a system under a compliance regime where public exposure is a finding on its own. All of the mechanisms below exist on Upsun. They fall into two families, and picking the family is most of the decision.

First, the constraint

Upsun is a cloud platform. Applications run on shared infrastructure in a public cloud region, which is what makes it affordable. Dedicated and on-premise setups exist, and they solve a different problem, but they carry the price of not sharing anything with anybody. For most teams that price is out of proportion to the actual requirement. The requirement is almost never “run this on hardware nobody else touches”. It’s “make sure no unauthorized request reaches this application”. Those are different problems, and the second one is solved by controlling reachability, not by owning the machine.

Family one: a public route with a locked door

The common case keeps the route. A CDN needs an origin to pull from, a partner integration can’t join your VPN, and somebody has to open the admin panel in a browser. The application stays addressable, and every request has to prove something before the router passes it through.

Mutual TLS

mTLS turns the usual TLS handshake around. The server proves its identity to the client as always, and the client also presents a certificate that the server validates against a certificate authority you decide on:
.upsun/config.yaml
Requests without a valid client certificate are rejected at the router and never reach the application. Requests that pass arrive with X-Client-Verify and X-Client-Cert headers, so the application can log which client it was talking to or authorize per client on top. The reason to reach for this over a shared secret is rotation. Plenty of organizations require credentials to be rotated on a schedule, and a shared secret in a header means coordinating both ends every time. With mTLS the rotating part is the client certificate, which the client rotates on its own, while the server side holds a CA certificate that contains a public key and no secret. Root CA certificates are commonly issued with lifetimes of 10 to 20 years, and public trust store roots sit at the top of that range. The part that rotates stays on the client side, and the route configuration goes untouched for years.

A shared secret in a header

The simplest version of the locked door is a value the client has to send. Upsun’s HTTP access control implements this as HTTP basic authentication for a whole environment:
Requests without the credentials get a 401 from the router. One detail matters if the application does its own token checking: on routes protected by HTTP access control, the Authorization header is removed before the request reaches the application, because Upsun consumes those credentials itself. Application-level tokens belong in a header of your own, checked in application code. That variant, a header the application requires and every other request rejected, is what most CDN setups use. Fastly or Cloudflare adds a secret header at the edge, the origin refuses anything without it, and the origin address stops being interesting to anyone who discovers it. It works well and it has the rotation problem described above. Treat it as the pragmatic option, not the strong one, and prefer mTLS wherever the client can be made to hold a certificate.

An IP allowlist

If the caller has a static, dedicated address, allow that address and deny the rest. HTTP access control does this too:
Two conditions decide whether this is worth anything. The address has to be static, or the allowlist becomes an on-call task. And it has to be dedicated, because an address shared with other tenants of some provider allows every one of those tenants along with your caller. Note that basic authentication and IP filtering are alternatives in this setting, not layers. If both are configured, a request with valid credentials passes from any address at all.

Family two: no public route at all

The stronger option, where the clients allow it, is an application the router never sends anything to. Nothing listens on the internet, so there’s no endpoint to scan and no login page to brute-force. Access happens over a channel you set up yourself. Start by making sure there’s no route. This is the step people get wrong, because omitting the routes key entirely gives you the default route, which sends all traffic to your app. To have no public entry point, define the key with nothing pointing at the application:
.upsun/config.yaml
Everything else keeps working. The application builds and deploys, cron jobs run, SSH works, and logs flow. The only thing missing is the public path in. Then pick how you get in.

A VPN

Put the container on a private network and reach it from devices already on that network. Tailscale on Upsun covers the setup, including the part that trips people up: Upsun containers can’t create a tun device, so Tailscale runs in userspace networking mode. That post uses the outbound direction, where the application reaches endpoints inside the tailnet. Exposing the application to the tailnet is the mirror image of it, with tailscale serve publishing the local port to the network instead. The container dials out to join the tailnet. Nothing has to reach in for the connection to exist. This is the option to reach for when the audience is people rather than machines. Access follows the identity and device policy you already run in the VPN, and revoking someone there revokes their access to the application at the same time.

A tunnel

A VPN gives every device on the network a path to the application. A tunnel gives one process a path, which is less to keep track of. Chisel carries TCP over HTTP, and the post above uses it to connect services across Upsun projects. Running over HTTP is what makes it convenient: the traffic looks like ordinary web traffic and travels wherever web traffic is allowed. For a human at a terminal, the Upsun CLI already does this. upsun tunnel:open opens SSH tunnels to the services in an environment and prints local addresses to connect to, and upsun ssh -L forwards a local port to the application itself. Nothing to configure and nothing to deploy, which makes it the right answer for occasional access by someone who already has SSH access to the project. The same commands work unattended. Add a dedicated account to the project with only the access it needs, give it an API token, and a backup job or a reporting script opens the tunnel, does its work, and closes it. The token is a credential on a rotation schedule, which is the cost of this option, and the account is one you can revoke on its own without touching anyone else’s access. The tradeoff across this family is the same one. Removing the public route also removes the browser as a client. Anyone who needs the application has to be on the network or hold the tunnel open first, and that’s a real cost for a tool used daily by non-technical colleagues.

Layers, and how many are too many

Defense in depth is the reason to combine any of this: when one control fails, another one is still standing. The useful version has each layer covering a distinct failure, and the wasteful version stacks two controls against the same one. An application on the VPN with no public route, plus an IP allowlist, plus a token, is the wasteful version. There’s no public route for the allowlist to filter and no unauthenticated caller for the token to stop. What it adds is configuration to maintain and a way to lock yourself out during an incident. Layers that pull in different directions are worth having. A CDN with a WAF in front handles volume and known attack patterns; mTLS at the origin handles anyone who bypasses the CDN; authorization inside the application handles an authenticated client asking for data belonging to a different client. Each of those catches something the others don’t.

Choosing

These options overlap more than the section headings suggest. A CDN origin can be locked with mTLS, with a shared secret header, with an IP allowlist, or with none of those and a tunnel between the CDN and the origin instead. Teams have shipped all four. What usually decides it is who the client is and what it can be made to do. A client you control can hold a certificate. A partner integration written five years ago might only manage an extra header. People with browsers find tunnels tiring and a VPN they already use invisible, while a nightly job is the other way around. An IP allowlist needs a caller whose address is static and yours alone, which rules it out more often than expected. The rotation question is worth asking early, because it’s the one that gets expensive later. Where credentials have to rotate on a schedule, an option that keeps the rotating part on the client side costs less over the years than one that needs both ends changed together. None of this is unusual infrastructure. It’s a route definition, an access rule, or a process that dials out, and all of it lives in the project configuration and gets reviewed like any other change. Pick the wrong one and you edit the file and redeploy.
Last modified on September 3, 2026