Exposing Preview URLs Automatically for Every Environment
The Short Version
A pull request environment that deploys without error but produces no working link has not actually done its job. The point of a preview environment is that a reviewer, a QA engineer, or a product manager can click one URL and see the change running, without asking anyone how to reach it. That URL has to exist automatically, the moment the environment finishes deploying, with no manual DNS record, no hand-edited ingress rule, and no Slack message asking "what's the link for this one." Here is the mechanism that makes that automatic, and the places it quietly breaks when a team wires it up by hand instead.
Why "It Deploys" Isn't the Same as "It's Reachable"
CI pipelines are good at reporting binary outcomes: the build passed, the deploy step exited zero, the pods are running. None of that tells a reviewer where to click. A Kubernetes deployment can be entirely healthy, every pod green, every readiness probe passing, and still be completely unreachable from outside the cluster if nothing has wired an ingress rule and a DNS record to that service.
This gap matters more than it looks like on paper, because it is exactly the point where automation tends to stop. Teams frequently automate the deploy step and leave URL exposure as a manual follow-up, something a developer configures once for the first environment and then never revisits, which works fine for a single long-lived staging environment and completely falls apart for a fleet of ephemeral, per-PR environments that come and go dozens of times a day.
How Bunnyshell Assigns a URL to Every Environment
Every environment gets a unique, deterministic identifier the moment it is created, exposed through interpolation as {{ env.unique }} (a short generated string, something like qmrxve) and {{ env.base_domain }} (the shared domain the environment platform manages DNS and ingress for). Any component that declares a hosts block gets a hostname built from these values, and that hostname resolves as soon as the ingress record is created, which happens automatically as part of the same deploy step that starts the component's pod.
1components:
2 - kind: Application
3 name: web
4 gitRepo: 'https://github.com/org/frontend.git'
5 gitBranch: '{{ component.gitBranch }}'
6 hosts:
7 - hostname: 'web-{{ env.base_domain }}'
8 servicePort: 3000
9 k8s:
10 ingress:
11 className: bns-nginxThere is no separate step here. The same bunnyshell.yaml that describes the component's build and runtime also describes its public URL, and both get created together when the environment deploys. Deleting the environment removes the ingress record along with everything else, so a destroyed PR environment's old link simply stops resolving rather than lingering as a dead but technically-reachable endpoint.
The Hosts Block, Concretely
The full shape of a hosts entry has a few fields worth understanding individually, since each one solves a specific real problem:
1hosts:
2 - hostname: 'web-{{ env.base_domain }}'
3 path: /
4 servicePort: 3000
5 public: false
6 k8s:
7 ingress:
8 className: bns-nginx
9 tlsSecretName: ''
10 annotations: {}hostname is the actual URL reviewers open. Using {{ env.base_domain }} interpolation means the exact same YAML produces a different, unique hostname for every environment created from it, with no per-environment editing required. servicePort tells the ingress which port on the component's container to route to, which matters the moment a component exposes more than one port (an app serving both an HTTP API and a metrics endpoint, for instance). public controls whether this specific host bypasses any environment-wide IP allowlist, useful when most of an environment should stay restricted but one health-check endpoint needs to stay open to an external uptime monitor. tlsSecretName left as an empty string signals automatic certificate provisioning through cert-manager rather than requiring a pre-existing secret.
None of these fields require manual intervention per environment. They are written once, in the template, and every environment created from that template inherits the pattern with its own unique values substituted in automatically.
Posting the URL Back Where Reviewers Actually Look
A URL that only exists inside the environment platform's dashboard is still a URL nobody finds in practice. The actual habit loop that makes automatic exposure valuable is posting the URL directly into the pull request itself, as a comment, the moment the environment finishes deploying, so the reviewer sees it in the same place they already review code.
1ENV_URL=$(bns environments show --id "$ENV_ID" -o json | \
2 python3 -c "import json,sys; d=json.load(sys.stdin); print(d['components'][0]['hosts'][0]['url'])")
3
4gh pr comment "$PR_NUMBER" --body "Preview environment ready: ${ENV_URL}"This runs as a step at the end of the deploy pipeline, after the environment reports a running state, and needs nothing more than the CI job's existing GitHub token. See Webhook-Triggered Environment Updates for the fuller pattern of keeping this comment updated across every subsequent push to the same PR, not just the first one, so the link a reviewer clicked on Monday still points at Wednesday's latest deploy instead of a stale build.
Multiple Services, Multiple URLs
Most real applications are not a single component. A typical stack might have a frontend, an API, and sometimes a separate admin panel, each of which a reviewer might need to reach independently during testing. Each one gets its own hosts block, and each one gets its own hostname under the same environment's base domain:
1components:
2 - kind: Application
3 name: web
4 hosts:
5 - hostname: 'web-{{ env.base_domain }}'
6 servicePort: 3000
7
8 - kind: Application
9 name: api
10 hosts:
11 - hostname: 'api-{{ env.base_domain }}'
12 servicePort: 8080
13
14 - kind: Application
15 name: admin
16 hosts:
17 - hostname: 'admin-{{ env.base_domain }}'
18 servicePort: 4000Because all three share the same env.base_domain value, they are visibly grouped together as belonging to the same environment (web-qmrxve.example-domain.com, api-qmrxve.example-domain.com, admin-qmrxve.example-domain.com), which is a small detail that saves real confusion once a team has more than a handful of concurrent PR environments open and needs to tell at a glance which URLs belong together.
Cross-Component References Instead of Hardcoded URLs
One easy mistake is hardcoding a sibling component's URL into a config file or an environment variable, which then breaks the moment the same template gets used for a different environment with a different generated subdomain. The fix is referencing the other component's actual hostname through interpolation rather than writing it as a literal string:
1components:
2 - kind: Application
3 name: web
4 dockerCompose:
5 environment:
6 API_BASE_URL: 'https://{{ components.api.ingress.hosts[0].url }}'This resolves to the API component's real, generated URL at deploy time, for every environment created from this template, without anyone needing to update it per environment. It is the same interpolation mechanism used throughout bunnyshell.yaml, just applied to a hostname instead of a variable value.
What Breaks This (and How to Avoid It)
A few specific mistakes account for most of the "the environment deployed but there's no link" reports:
Missing hosts block entirely. A component with no hosts entry deploys and runs fine, it just never gets an ingress record, which is correct behavior for an internal-only service (a worker, a database) but a real bug for anything a human is meant to open in a browser.
Wrong servicePort. If the container listens on a different port than the one declared in hosts, the ingress creates successfully and the URL resolves to a connection reset or a timeout, which looks identical to a genuine deploy failure from the reviewer's side and wastes time debugging the wrong layer.
Hardcoded hostnames copied from a working example. Copying a working hosts block from one component to another without updating the interpolated values (or, worse, hardcoding an actual domain string instead of using {{ env.base_domain }}) produces a hostname collision the moment two environments exist at once, since both would try to claim the exact same literal hostname.
PR comment step not wired to re-run on redeploy. If the URL-posting step only runs on the environment's initial creation and not on subsequent pushes to the same PR, the link posted in the first comment can go stale once the underlying deploy changes, particularly for components that move to a new pod but keep the same hostname; the fix is re-running the comment step (or updating the existing comment in place) on every deploy pipeline completion, not just the first one.
Custom Domains vs the Default Subdomain
The default {{ env.base_domain }} pattern is the right choice for the overwhelming majority of PR and staging environments, since it requires zero DNS configuration and works identically for every environment created from the template. A custom domain (staging.yourcompany.com instead of the generated subdomain) is worth the extra setup specifically for a small number of long-lived, primary environments that get shared externally, a sales demo environment or a stable staging instance customers occasionally see, where a branded domain matters more than the convenience of zero configuration. See Custom Domains and Network Isolation for Staging Environments for the DNS and TLS steps that pattern actually requires, since it is meaningfully more setup than the automatic subdomain approach described here.
Wildcard DNS Is What Makes This Instant
The reason a brand new hostname like web-qmrxve.example-domain.com resolves immediately, with no per-environment DNS step, is a wildcard DNS record pointed at the ingress controller's load balancer, configured once at the platform level rather than once per environment. *.example-domain.com resolves to the same load balancer IP regardless of what subdomain gets requested, and the ingress controller itself is what decides, based on the Host header of the incoming request, which backend service that specific subdomain actually routes to. This is why creating a new environment never involves a DNS propagation wait: the DNS side was already solved the moment the wildcard record was configured, and every new environment after that is purely an ingress-rule change, which takes effect in seconds rather than the minutes-to-hours a fresh DNS record can take to propagate.
This also explains a detail that occasionally confuses teams debugging a URL that will not resolve: a typo in the ingress rule's hostname produces a different failure mode than a typo in an actual DNS record would. Since the wildcard already covers any subdomain under the base domain, a mistyped hostname still resolves at the DNS layer (any subdomain does), but then fails at the ingress layer with a 404, because no ingress rule matches that specific, misspelled Host header. Recognizing this distinction saves time: a URL that returns a 404 from the ingress controller itself (rather than a DNS resolution failure) points at an ingress-rule mismatch, not a DNS problem, and the fix is almost always a typo in the hostname field of the hosts block.
Don't Announce the URL Before the Component Is Actually Ready
A subtler failure than a missing hosts block: posting the preview URL to the PR the instant the deploy pipeline reports success, before the application container has actually finished its own startup sequence and is ready to serve traffic. Kubernetes reports a pod as running once its container process has started, which for many applications (anything with a meaningful boot sequence: cache warming, running pending migrations, establishing a connection pool) is earlier than the moment it can actually serve a request correctly.
1components:
2 - kind: Application
3 name: web
4 hosts:
5 - hostname: 'web-{{ env.base_domain }}'
6 servicePort: 3000
7 k8s:
8 readinessProbe:
9 httpGet:
10 path: /healthz
11 port: 3000
12 initialDelaySeconds: 5
13 periodSeconds: 3A readiness probe gates when the ingress actually starts routing traffic to the pod, independent of when the pod itself started, so tying the PR-comment step to the environment reporting fully ready (not just deployed) avoids the awkward case of a reviewer clicking a freshly-posted link and hitting a connection error for the ten seconds the application was still finishing its boot sequence.
Debugging a URL That Times Out Instead of 404ing
A URL that returns a clean 404 is an ingress-rule problem, diagnosable by checking the hosts block for a typo or a missing entry. A URL that hangs and times out instead is usually a different, one-layer-deeper problem: the ingress rule exists and matches correctly, but the backend service it points at cannot actually reach a healthy pod, either because servicePort does not match the port the container is actually listening on, or because the pod itself is not in a ready state yet despite the ingress already routing to it.
1$ curl -v https://web-qmrxve.example-domain.com/
2* Connected to web-qmrxve.example-domain.com (203.0.113.10) port 443
3> GET / HTTP/1.1
4* Empty reply from serverAn "empty reply" or a timeout after a successful TLS connection, rather than an immediate connection refusal, points specifically at the backend, not the ingress or DNS layer, confirming the ingress rule and hostname resolution are both working and the actual problem is the component behind them.
Related Reading
For the full lifecycle this URL exposure step fits into (create on PR open, redeploy on push, destroy on merge), see Automated PR Environments for E2E Testing. For keeping the posted URL current across every subsequent push rather than just the first, see Webhook-Triggered Environment Updates.
Checklist
- Every component a reviewer needs to open in a browser has a
hostsblock - Hostnames use
{{ env.base_domain }}interpolation, never a hardcoded literal domain -
servicePortmatches the actual port the container listens on - Cross-component URL references use
{{ components.NAME.ingress.hosts[0].url }}instead of hardcoded sibling URLs - The PR comment step posting the URL re-runs on every redeploy, not just environment creation
Give every environment a working URL the moment it deploys.
14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.
Frequently Asked Questions
Does every component in an environment get its own URL?
Only components with a hosts block defined. A background worker or a database component typically has no hosts entry and stays internal to the environment, reachable only from other components inside it, while an application or frontend component that reviewers need to open in a browser gets one.
What happens to the URL when the environment is destroyed?
The URL stops resolving as soon as the ingress record and the underlying pod are torn down, which for an ephemeral PR environment usually happens automatically on merge or PR close, so a stale preview link left in an old PR comment simply returns nothing rather than staying live indefinitely.
Can two different pull requests get the same subdomain by accident?
No. The default subdomain is generated from the environment identifier, not the branch or PR title, so two environments never collide on the same hostname even if two branches happen to share a name.
Do I need to configure DNS manually for this to work?
Not for the default base-domain pattern. DNS for the shared base domain is already wired to the ingress controller, so a new hostname under it resolves immediately once the environment deploys. Manual DNS only comes into play for a custom domain, which is a separate, optional step.
What if a reviewer needs to hit the API directly instead of the frontend?
Add a hosts entry to the API component the same way it is added to the frontend. Each component can expose its own hostname, so reviewers can open the frontend and hit the API independently, both from the same automatically generated base domain.