> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How to host a website that isn't publicly reachable

> Some applications should never answer a request from the open internet. Here are the ways to make one unreachable on Upsun, and how to pick between them.

export const PostMeta = ({data = {}}) => {
  const {author, date} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const toSlug = value => String(value).toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const url = `/posts/authors/${toSlug(slug)}`;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna",
      "github": "jackrabbithanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud",
      "linkedin": "https://www.linkedin.com/in/laurent-arnoud-861b44121/"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "maz-mohammadi": {
      "name": "Maz Mohammadi"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
    </div>;
};

<PostMeta data={{ author: ["ralt"], date: "2026-09-03T08:00:00.000Z" }} />

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](/docs/routes/https#enable-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:

```yaml title=".upsun/config.yaml" theme={null}
  routes:
    "https://{default}/":
      type: upstream
      upstream: "app:http"
      tls:
        client_authentication: "require"
        client_certificate_authorities:
          - !include
            type: string
            path: root-ca.crt
```

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](/docs/environments/http-access-control) implements this as HTTP basic authentication for a whole environment:

```bash theme={null}
  upsun environment:http-access -e main --auth ops:s3cret
```

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](/docs/development/headers) 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](/docs/environments/http-access-control#filter-ip-addresses) does this too:

```txt theme={null}
  198.51.100.0 allow
  0.0.0.0/0 deny
```

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](/docs/routes#default-route-definition), which sends all traffic to your app. To have no public entry point, define the key with nothing pointing at the application:

```yaml title=".upsun/config.yaml" theme={null}
  routes: {}
```

Everything else keeps working. The application builds and deploys, [cron jobs](/docs/configure-apps/image-properties/crons) run, [SSH](/docs/development/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](/posts/hands-on/run-tailscale-on-upsun-secure-vpn-connections) 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](/posts/hands-on/connect-multiple-projects-applications-or-services-together) 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`](/docs/development/ssh#use-a-direct-tunnel) 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](/cli/api-tokens), 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.
