The deploy went green, the domain resolves, and the health checks pass. Then somebody opens the telemetry dashboard and every trace from the migrated service is tagged platform: unknown. Nothing is broken, exactly — but the app no longer knows where it runs, because the migration moved the code and left the runtime detection contract behind.
Every platform injects its own identity into the processes it runs: Heroku sets DYNO, Railway sets a dozen RAILWAY_* variables, Fly sets FLY_APP_NAME, Cloud Run sets K_SERVICE, Render sets RENDER_SERVICE_NAME. Application code sniffs these variables to decide log tags, metrics dimensions, region-aware routing, and even which health-check path to expose. That sniffing does not stop the day you migrate — it just starts returning different answers. A Render-compatible self-hosted API therefore owes its tenants more than a compatible deploy endpoint: it owes them the exact detection surface their code was written against, and a deliberate decision about every variable it refuses to fake.
The precedence chain every deploy target resolves
Real-world deploy code resolves "where am I?" through a strict precedence chain. Platform-injected variables win first; if none match, the code falls back to whatever the build artifact stamped into itself; then to container-presence markers like /.dockerenv; and finally to a hardcoded 'source' fallback that means "I give up." In sketch form:
import os
def detect_platform() -> str:
if os.environ.get("DYNO"):
return "heroku"
if os.environ.get("RAILWAY_SERVICE_ID"):
return "railway"
if os.environ.get("RENDER_SERVICE_ID"):
return "render"
if os.environ.get("FLY_APP_NAME"):
return "fly"
if os.environ.get("K_SERVICE"):
return "cloud-run"
if os.environ.get("COOLIFY_RESOURCE_UUID"):
return "coolify"
if os.environ.get("BUILD_PLATFORM"): # stamped at build time
return os.environ["BUILD_PLATFORM"]
if os.path.exists("/.dockerenv"):
return "docker-unknown"
return "source"This is not a hypothetical shape. Cloud-runtime detection libraries exist precisely because so much code needs this answer: one Java SPI keys Heroku detection off DYNO alone, and an OpenTelemetry resource detector maps RAILWAY_PROJECT_NAME, RAILWAY_ENVIRONMENT_NAME, and RAILWAY_REPLICA_ID straight into span attributes. Trace the function above across a migration and the failure is obvious. On Railway it returns "railway" at the second check. Move the same image to a host that injects nothing, and the same function slides all the way down to "source" — every dashboard filter, alert rule, and region branch built on that string silently changes meaning while the deploy itself reports success.
The chain also explains why the fix is not "tell tenants to update their detection code." The sniffing lives in health-check endpoints, telemetry initializers, and third-party buildpacks and detectors the tenant did not write and cannot easily change. The platform has to meet the code where it is.
What each platform injects that apps actually sniff
Before deciding what to inject, inventory what migrated apps are listening for. These are the documented, platform-injected variables detection code keys on — not tenant config, but identity the runtime provides unasked:
| Platform | Detection surface apps sniff |
|---|---|
| Heroku | DYNO (always set); with the runtime-dyno-metadata labs flag, HEROKU_APP_NAME, HEROKU_APP_ID, HEROKU_RELEASE_VERSION, HEROKU_SLUG_COMMIT, and siblings |
| Railway | RAILWAY_SERVICE_ID, RAILWAY_SERVICE_NAME, RAILWAY_ENVIRONMENT_NAME, RAILWAY_ENVIRONMENT_ID, RAILWAY_PROJECT_NAME, RAILWAY_PROJECT_ID, RAILWAY_REPLICA_ID, RAILWAY_REPLICA_REGION, RAILWAY_DEPLOYMENT_ID, RAILWAY_PUBLIC_DOMAIN, RAILWAY_PRIVATE_DOMAIN, plus RAILWAY_GIT_* and PORT — provided to all builds and deployments |
| Render | RENDER_SERVICE_ID, RENDER_SERVICE_NAME, RENDER_SERVICE_TYPE, RENDER_INSTANCE_ID, RENDER_EXTERNAL_HOSTNAME, RENDER_EXTERNAL_URL, RENDER_GIT_COMMIT, RENDER_GIT_BRANCH, RENDER_GIT_REPO_SLUG, RENDER_DISCOVERY_SERVICE, RENDER_CPU_COUNT, RENDER_WEB_CONCURRENCY, PORT, and NODE_ENV on the Node native runtime |
| Fly.io | FLY_APP_NAME, FLY_MACHINE_ID, FLY_REGION, PRIMARY_REGION — and user variables are forbidden from starting with FLY_, so the prefix is unambiguous signal |
| Cloud Run | K_SERVICE, K_REVISION, K_CONFIGURATION, and PORT (default 8080, ingress container only) under a published container contract; the names are reserved and cannot be overridden |
| Coolify | COOLIFY_FQDN, COOLIFY_URL, COOLIFY_BRANCH, COOLIFY_RESOURCE_UUID, COOLIFY_CONTAINER_NAME (runtime only), with an explicit build-time vs runtime split |
| Kubernetes | No single env prefix, but KUBERNETES_SERVICE_HOST plus the serviceaccount token at /var/run/secrets/kubernetes.io/serviceaccount/token — a file-presence signal, not a variable |
Two patterns matter for what comes next. First, every managed platform treats its prefix as reserved namespace — Cloud Run rejects overrides outright, Fly refuses user variables starting with FLY_ — because detection code trusts these names to mean exactly one thing. Second, the Kubernetes signal is a file, not a variable, which means any K8s-based self-hosted PaaS introduces a detection input the tenant's old host never had. Both facts constrain the contract.
The inject list: what a Render-compatible API must provide
"Render-compatible" is a claim about this table's Render row, and compatibility here is all-or-nothing per variable: detection code does not grade on a curve. If a tenant's telemetry initializer reads RENDER_SERVICE_NAME for the service dimension and RENDER_GIT_COMMIT for the deploy marker, injecting one without the other produces traces that are half-labeled — arguably worse than unknown, because they look complete. So the inject list is the full documented Render default set, each with its real semantics:
- Identity:
RENDER_SERVICE_ID,RENDER_SERVICE_NAME,RENDER_SERVICE_TYPE— the primary keys detection code branches on. Types must use Render's own vocabulary (web service, private service, background worker, cron job, static site) or downstream==comparisons fail. - Instance:
RENDER_INSTANCE_ID— the replica distinguisher. Apps use it the way Railway apps useRAILWAY_REPLICA_ID: log correlation and single-flight leadership. It must be unique per running instance and stable for its lifetime. - Networking:
RENDER_EXTERNAL_HOSTNAME,RENDER_EXTERNAL_URL,RENDER_DISCOVERY_SERVICE— the values apps hand to OAuth callbacks, webhook registrations, and service-to-service dialing.PORTbelongs here too: Render assigns it, and the process must listen on it. - Provenance:
RENDER_GIT_COMMIT,RENDER_GIT_BRANCH,RENDER_GIT_REPO_SLUG— the deploy markers that annotation and "what's running" endpoints report. - Sizing:
RENDER_CPU_COUNT,RENDER_WEB_CONCURRENCY— consumed by worker-count math (WEB_CONCURRENCY-style fork counts) at boot. Wrong values do not cause detection failures; they cause silently mis-sized processes, which is harder to notice. - Runtime behavior:
NODE_ENVset automatically on the Node runtime, matching Render's documented behavior — frameworks branch on it everywhere.
Values are always strings, available at both build and runtime unless documented otherwise. That last clause is load-bearing: build-time detection (a Next.js build stamping its public URL, a telemetry SDK baking the service name into a bundle) reads the same names during the build phase. A compatible API that injects these only at runtime will pass a container-level check and still ship artifacts stamped unknown.
The do-not-fake list: variables you must leave absent
The inject list invites its evil twin: if tenants' code sniffs DYNO and RAILWAY_SERVICE_ID, why not set those too, so migrated apps "just work"? Because detection variables are claims about reality, and a false claim is worse than silence at every level of the chain. The do-not-fake list, with reasons:
DYNOandHEROKU_*: Heroku detection is a one-variable check —DYNOset means Heroku, full stop. Faking it tells every Heroku-aware library (buildpacks, error reporters, APM agents) to enable Heroku-specific behavior: Heroku routing-header handling, dyno-restart expectations, log-drain formats. None of that is true on your fleet, and the failure modes (requests routed by headers you never send) are silent.RAILWAY_*: Railway's set is deep — project, environment, service, replica, deployment, region, TCP proxy. Faking the easy ones (service name) without the load-bearing ones (replica region, private domain) gives region-aware code a confident wrong answer.RAILWAY_REPLICA_REGIONdriving "serve from the nearest bucket" now picks a region by fiction. AndRAILWAY_PRIVATE_DOMAINresolving to nothing turns service discovery into DNS timeouts.FLY_*: Fly reserves the entire prefix and injects region and machine identity that apps use for replay-routing (fly-replaylogic keys offFLY_REGION). A spoofed region does not degrade gracefully — it replays requests to regions that do not exist in your topology.K_SERVICE,K_REVISION,K_CONFIGURATION: Cloud Run documents these as reserved and unsettable for a reason: Knative-shaped tooling treats their presence as proof of the Knative contract (revision immutability, scale-to-zero semantics). Claiming them without the contract invites autoscaling and traffic-splitting assumptions your platform cannot honor.COOLIFY_*: Same logic at smaller scale — Coolify-aware scripts readCOOLIFY_FQDNandCOOLIFY_BRANCHas deployment truth. If your platform is not the thing that assigned those values, they are lies with your signature on them.
The gray zone is small and principled: inject what is genuinely yours or genuinely universal. PORT is universal — every platform in the table assigns it, and listening on it is the closest thing this ecosystem has to a shared syscall. NODE_ENV, TZ, and locale variables are runtime facts, not platform claims. Everything with a platform's prefix on it belongs to that platform; your API's honesty is measured by the prefixes it leaves alone.
There is a real cost to this honesty — the migrated Railway app's dashboard filter says unknown until the tenant updates it — but it is a visible, greppable cost. Spoofed variables produce confident wrong behavior in code paths nobody re-reads after migrating. Absent variables produce a fallback branch the tenant can find, understand, and fix once.
The layers below the vars: /.dockerenv and the serviceaccount token
Below the variable layer, two file-presence signals cross every migration unchanged — and one of them is new. /.dockerenv is the canonical "am I in Docker?" marker: Docker creates it in every container, detection snippets from Microsoft's auth library to one-liner shell probes check it first, and Podman answers the same question with /run/.containerenv instead. Because these markers describe the container runtime rather than the platform, they survive migration untouched — a Render-compatible API running OCI containers keeps this layer of the chain telling the truth for free.
The Kubernetes serviceaccount token is the opposite: a signal your platform introduces that the old host never had. Any PaaS built on Kubernetes mounts /var/run/secrets/kubernetes.io/serviceaccount/token into tenant containers by default, and Kubernetes-aware detection (client libraries, Helm-chart probes, "am I in-cluster?" checks) treats its presence as proof of in-cluster execution. A Heroku or Railway migrant has never seen this file; on your fleet, code paths that never fired before — in-cluster config loading, serviceaccount-credential use — suddenly activate. This is not a reason to strip the mount (workload identity is genuinely useful), but it is a documented behavior change owed to every migrant: "these are the new true signals your code will now see." The same duty covers KUBERNETES_SERVICE_HOST, which kubelet injects into every pod and which no non-Kubernetes host ever sets.
The container layer has its own cautionary tale about over-eager detection: a is_running_in_docker() helper that matched substrings in the PID 1 command produced false positives on plain hosts, a reminder that every layer below explicit platform variables is heuristic. Prefer the explicit variable when you have one; treat file and cgroup evidence as advisory.
The contract, as a checklist
Runtime detection is a compatibility surface with the same standing as your deploy endpoint: tenants' code depends on it, migration guides ignore it, and breakage shows up in dashboards and routing decisions rather than in deploy logs. If you run a Render-compatible API on machines you own, this is the contract to hold:
- Inject the full Render default set — every
RENDER_*variable,PORT, and runtimeNODE_ENV— with Render's semantics, at build time as well as runtime. - Fake no other platform's prefix. No
DYNO, noRAILWAY_*, noFLY_*, noK_*, noCOOLIFY_*. Absence is a truthful answer; spoofing is a confident lie. - Document the new true signals — the serviceaccount token,
KUBERNETES_SERVICE_HOST, and your own platform prefix — so migrants learn what their code will newly detect instead of discovering it in production. - Keep
PORTsacred. It is the one variable every migrant already honors; meeting it exactly buys more compatibility per line than any other single name. - Version the contract. When you add a variable, it becomes detection input for every tenant the day it ships. Name it under your own prefix, reserve that prefix against tenant overrides, and announce it like the API change it is.
Migrations are judged by whether the app behaves identically on the new host — and "behaves" includes what the app believes about where it runs. Honor the detection contract, and the migrated app stops asking which PaaS it's on because every answer it gets is true.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



