Networking
How your services find each other on a private network, how they find their databases, and what the internet can reach.
The project network
Each project has one private network. Every service and managed database in the project joins it, and they talk to each other over it directly, without going through the public edge. Services in different projects are on different networks and cannot reach each other privately.
What the internet can reach depends on the service's type, which you choose when you create the service:
| Type | Reachable from the internet |
|---|---|
| Web service | Yes, over HTTPS at its *.hoststack.dev hostname and any custom domain you attach. |
| Static site | Yes, the same way as a web service. |
| Private service | No. Only other services in the project can call it — for internal APIs and gRPC backends. |
| Worker, Cron job | No. They get no public URL. |
A web service's hostname looks like web-a1b2.hoststack.dev; outside the Production environment it carries the environment, as in web-staging-a1b2.hoststack.dev.
Call another service
For every other active service in the same project and environment, HostStack sets a variable holding its private address. For a sibling named my-api that listens on port 8080:
HOSTSTACK_SVC_MY_API_INTERNAL_URL=http://hoststack-svc-42:8080 # prefer this one
MY_API_INTERNAL_URL=http://hoststack-svc-42:8080
ABCDEF1234567890_INTERNAL_URL=http://hoststack-svc-42:8080 # from the sibling's svc_… idconst res = await fetch(`${process.env.HOSTSTACK_SVC_MY_API_INTERNAL_URL}/health`);HOSTSTACK_SVC_ <NAME>_ INTERNAL_ URL The name is the sibling's name in upper case, with every character other than a letter or digit turned into
_. It sits in a namespace HostStack owns, so it cannot collide with an image's own configuration variables. Prefer it.<NAME>_INTERNAL_ URL The same address under the shorter name. Its key changes if the sibling is renamed.
<PUBLIC_ID>_ INTERNAL_ URL The same address keyed by the sibling's id without the
svc_prefix, in upper case. Its key never changes.
All three hold the same value: http://hoststack-svc-<id>:<port>, using the port the sibling itself listens on. The value never changes, not even on a rename. Inside a variable's value you can also write ${{services.my-api.url}} — see Environment variables.
Hostnames on the network
If you would rather write an address yourself, each service answers on:
hoststack-svc-<id>— the numeric id, the form the variables use.hoststack-svc-<svc_id>— itssvc_…id, lower case.- Its name, lower-cased with punctuation removed, such as
my-api. Outside Production the environment is appended:my-api-staging.
Preview deploys get their own names ending in -pr-<number> and never answer on the production names.
Renaming a service
You can rename a service at any time. HostStack then redeploys it and every active sibling in its environment, so the <NAME>_INTERNAL_URL keys follow the new name within a few minutes. The address itself and the <PUBLIC_ID>_INTERNAL_URL key do not change.
Internal URL or public URL
A service can also call a sibling by its public address, such as https://api.example.com. That works, but the request then leaves the project network and comes back in through the public edge, like a visitor's. For a call inside one project and environment, use the *_INTERNAL_URL address instead:
| Internal URL | Public URL | |
|---|---|---|
| Path | Straight to the sibling over the project network | Out to the edge and back in, with TLS both ways |
| Edge rate limit | None | Counts against the limit for its client address: 100 requests a second on average, bursts of 200, per address and domain |
| Client address the sibling sees | The caller’s address on the project network | A private address, explained below |
| Reachable from | The same project | Anywhere, including other projects |
Server-side rendering is the usual case. A front end that fetches from its own API on every page view sends every visitor’s request from one address. Over the public URL, that address hits the edge limit, and any per-IP limit in the API, as if it were a single, very busy visitor.
The edge limit is a flood guard sized for websites. An API that authenticates its callers and enforces its own per-key limits can raise it for every route of the service with edgeRateLimitRps in the service config (10 to 5,000 requests a second per address, bursts of twice that; MCP update_service_config edge_rate_limit_rps). Otherwise a customer you sell more than 100 requests a second gets a bare 429 from the edge that your app never sees or meters. It applies on the next deploy.
What your app sees from another service on the same machine
A request to a public URL from a container on the same machine reaches your app with a private client address, in 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16, in X-Real-Ip and X-Forwarded-For. It is the calling container’s address on its own project network, and it changes when that service redeploys. A caller on another machine arrives from that machine’s public address.
Reach a database or other resource
Link a managed resource to a service — on the service's Connections tab, or with hoststack db link db_... --service svc_... — and its connection details are injected on the next deploy, under an alias prefix:
| Resource | Default alias (dashboard) | Variables |
|---|---|---|
| Postgres, MySQL, MariaDB | DATABASE | _URL, _HOST, _PORT, _DATABASE, _USERNAME, _PASSWORD |
| Redis | REDIS | the same six |
| MongoDB | MONGO | the same six |
| Object storage | S3 | _ENDPOINT, _REGION, _BUCKET, _ACCESS_KEY_ID, _SECRET_ACCESS_KEY |
In the dashboard a second link of the same kind gets DATABASE_2 and so on. hoststack db link instead defaults the alias to the database's name in upper case (app-db becomes APP_DB); pass --alias to choose one. On top of the prefixed variables, the first linked SQL database also sets DATABASE_URL, the first Redis REDIS_URL and the first MongoDB MONGO_URL, the names most frameworks look for. See Managed databases.
Override or switch off an injected variable
Set the same key yourself — on the service or in an env group — and your value wins over the injected one. Set it to an empty value and the variable is not passed to the container at all. This works only for keys HostStack would have injected; an empty variable of your own is passed through as it is.
Clearing it takes two steps, and the second is easy to miss:
Stop the injection
bashhoststack env set svc_... POSTFIX_OUT_INTERNAL_URL=Then deploy. Call the sibling through
HOSTSTACK_SVC_POSTFIX_OUT_INTERNAL_URL, which is still set.Remove what an earlier boot already wrote
Needed whenever the image keeps its configuration on a volume —
boky/postfixkeeps/etc/postfixon one, somain.cfsurvives a redeploy. In the service's Terminal tab:bashpostconf -# OUT_INTERNAL_URL postfix reload
Step 2 is needed because step 1 worked: with the variable gone, the image never visits that key again, so it never cleans it up either.
Environments share the network
All environments of a project — Production, Staging, previews — are on the same project network. What keeps them apart is naming, not a network boundary:
- The
*_INTERNAL_URLvariables only ever point at siblings in the same environment, so a staging service is never handed a production address. - Name-based hostnames outside Production carry the environment (
my-api-staging), and previews carry-pr-<number>. - A
hoststack-svc-<id>address is still reachable from every environment in the project.
If two environments must not be able to reach each other at all, put them in separate projects.
What your app receives from the edge
Requests to a web service or static site pass through HostStack's edge, which adds the visitor's details as request headers. Private services are called directly on the project network, so they receive none of these; forward what you need from your public tier.
Visitor country
HostStack looks up each visitor's country from their IP address inside its own infrastructure — the address is never sent to a third party — and adds it to every request under two names:
Hs-Ipcountry The HostStack header.
Cf-Ipcountry The same value under Cloudflare's name, so code that already reads
CF-IPCountryworks unchanged.
The value is an upper-case ISO 3166-1 alpha-2 code such as DK or US. When the country can't be determined — a private address, an unknown range — the header is left out entirely, so check whether it is present rather than for a placeholder value. A country sent by the client itself is removed first, so the header cannot be spoofed.
app.get('/', (req, res) => {
const country = req.get('hs-ipcountry'); // "DK", or undefined
res.send(country ? `Hello from ${country}` : 'Hello');
});IP-based country lookup is wrong for some VPN, carrier-NAT and satellite users. Use it for analytics, localisation and routing, not as an identity or fraud control. Country data by DB-IP (CC BY 4.0).
Visitor IP address
Read X-Real-Ip, or the left-most entry of X-Forwarded-For. A request from another service on the same machine carries a private address here; see Internal URL or public URL.
Next steps
Was this page helpful?