The scariest sentence in Railway's April 2026 changelog is not about a feature at all. It is a confession: "Rolling Railway out to a large team means trusting that developers won't accidentally expose internal services to the public internet. Until now, there was no centralized way to enforce that." One mis-clicked "Generate Domain" or one casually enabled TCP proxy, and an internal Postgres or staging admin panel is reachable from anywhere — no deploy, no review, no audit trail beyond "someone toggled it."
Railway's answer was Guardrails: two workspace-wide policies that take those two buttons away from non-admins. If you run a self-hosted, multi-tenant platform on Kubernetes, you have the same two buttons, except yours are YAML, and your "developers" increasingly include AI deploy agents holding broad credentials. This post gives you the Kubernetes-native equivalent of both policies as working admission-control recipes — plus the namespace exceptions, approval flow, and audit trail that make them livable.
One toggle, every project: what Railway actually shipped
On April 3, 2026, Railway shipped Guardrails for enterprise workspaces with exactly two policies:
| Policy | What it blocks | Applies to |
|---|---|---|
| Restrict domain generation | Generating public *.railway.app service domains | Non-admin members |
| Restrict public TCP proxies | Creating public TCP proxies | Non-admin members |
Three design details matter more than the policy count. First, enforcement happens at the API level, not just in the dashboard — a CLI call or automation hitting a blocked action is rejected, not merely hidden. Second, while a policy is on, Railway also skips automatic generation for everyone, including admins: template-created services and new environments no longer auto-generate domains or proxies. Third, the policies are narrowly scoped: custom domains are unaffected, and services stay reachable over private networking, so internal traffic keeps working.
That is the bar: default-internal, admin-approved exposure, enforced below the UI. Now let's translate it.
The Kubernetes translation table
On a self-hosted PaaS, "generate a public domain" and "create a TCP proxy" are not buttons — they are objects a tenant (or their agent) can submit to the API server. Here is the mapping:
| Railway control | Kubernetes equivalent | Why it matters |
|---|---|---|
| Generate public domain | HTTPRoute with a public hostname, or attachment (parentRefs) to the public Gateway's listeners | One route object turns an internal Service into a public URL |
| Create public TCP proxy | Service of type LoadBalancer/NodePort, or TLSRoute/TCPRoute on the public Gateway | Raw TCP has no hostname scoping — a database port becomes globally dialable |
| Private networking (unaffected) | ClusterIP Services, mesh-internal routes | Must keep working; policies must not break east-west traffic |
The sneakiest path is TCP. An HTTP exposure at least carries a hostname you can pattern-match; a LoadBalancer Service or a TCPRoute opens a port with no name-based scoping at all. Railway was right to make it a separate policy, and so should you.
Two mechanisms enforce all of this below the UI: in-tree ValidatingAdmissionPolicy (CEL expressions, no webhook to operate, stable since Kubernetes 1.30) and Kyverno ClusterPolicy for the cases where you want background scanning and per-namespace reports. Use both: VAP for the sharp admission-time deny, Kyverno where you want PolicyReport evidence and richer matching.
Why admission policy rather than plain RBAC? Because RBAC authorizes verbs on resource types, not field values inside objects. It can say "this service account may create Services" but not "may create Services unless spec.type is LoadBalancer" — and it certainly cannot say "may create HTTPRoutes unless the hostname leaves the tenant suffix." The moment your rule mentions a value inside the object, you have outgrown RBAC and need admission control. That is precisely the gap Railway's Guardrails fill on their side, and the gap these two policies fill on yours.
Policy 1: no public HTTP without approval
Gateway API deliberately defines no built-in route-to-route hostname ownership — its isolation answer is per-listener attachment, and hostname discipline is left to admission policy. Real platforms already do this: Deckhouse ships a set of ValidatingAdmissionPolicy objects reserving the platform's own public hostnames from tenant namespaces, and several Gateway controllers document opt-in VAPs for route validation. Here is the tenant-facing version:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: restrict-public-http-routes
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["gateway.networking.k8s.io"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["httproutes"]
# Exception namespaces carry exposure.example.com/approved: "true"
# and are the ONLY path to the public Gateway (see below).
namespaceSelector:
matchExpressions:
- key: exposure.example.com/approved
operator: NotIn
values: ["true"]
paramKind:
apiVersion: v1
kind: ConfigMap
validations:
- expression: "!has(object.spec.hostnames) || object.spec.hostnames.all(h, h.endsWith(params.data.allowedSuffix))"
message: "HTTPRoute hostnames must stay inside the tenant suffix. Ask an admin for a public hostname."
- expression: "!has(object.spec.parentRefs) || object.spec.parentRefs.all(p, !(p.name == params.data.publicGateway && (!has(p.namespace) || p.namespace == params.data.gatewayNamespace)))"
message: "Only approved namespaces may attach routes to the public Gateway."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: restrict-public-http-routes-binding
spec:
policyName: restrict-public-http-routes
paramRef:
name: public-exposure-params
namespace: platform-system
validationActions: [Deny]What this buys you, clause by clause: tenant namespaces can create all the HTTPRoute objects they want, but hostnames must end with the tenant suffix (say, .tenants.example.com) and no route may attach to the public Gateway's listeners. The namespaceSelector exclusion is the exception path — and because namespaces are cluster-scoped, tenants holding only namespaced Roles can never label their way out. Only someone with cluster-level RBAC (your admins, or your GitOps service account) can add the label. That is exactly Railway's "hidden for non-admins," except the enforcement point is the API server, so a deploy agent with a tenant-scoped kubeconfig is constrained the same way a dashboard user is.
Policy 2: no public TCP without approval
TCP deserves its own policy for one reason: blast radius. A stray LoadBalancer Service on a cloud provider provisions a public IP and forwards raw bytes; on bare metal with MetalLB it claims an address from your public pool. There is no hostname to audit, no TLS handshake to log — just an open port. Databases are the classic casualty: the service someone exposes "temporarily to debug from home" is almost always Postgres or Redis.
A Kyverno ClusterPolicy fits here because you want background mode (flag violations that predate the policy) and PolicyReport evidence per namespace:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: restrict-public-tcp
annotations:
policies.kyverno.io/severity: high
policies.kyverno.io/category: Security
spec:
validationFailureAction: Enforce
background: true
rules:
- name: block-public-service-types
match:
any:
- resources:
kinds: ["Service"]
namespaceSelector:
matchExpressions:
- key: exposure.example.com/approved
operator: NotIn
values: ["true"]
validate:
message: "LoadBalancer and NodePort Services need admin approval. Use ClusterIP plus an approved route instead."
deny:
conditions:
any:
- key: "{{ request.object.spec.type }}"
operator: AnyIn
value: ["LoadBalancer", "NodePort"]
- name: block-public-tcp-routes
match:
any:
- resources:
kinds: ["TLSRoute", "TCPRoute"]
namespaceSelector:
matchExpressions:
- key: exposure.example.com/approved
operator: NotIn
values: ["true"]
validate:
message: "TLSRoute/TCPRoute attachment to the public Gateway needs admin approval."
deny:
conditions:
any:
- key: "{{ request.object.spec.parentRefs[].name }}"
operator: AnyIn
value: ["public-gateway"]Note what is not restricted, mirroring Railway's scoping discipline: ClusterIP Services, mesh-internal routing, and custom-domain handling inside already-approved namespaces all keep working. Internal traffic is unaffected; only the creation of new internet-facing surface requires approval.
Exceptions, approvals, and the audit trail
A deny-by-default policy without a legible exception path becomes a ticket queue that everyone hates — or worse, a policy someone disables during an incident and never re-enables. Three pieces keep it livable:
Exceptions are labels, changed only via GitOps. The exposure.example.com/approved: "true" namespace label is the single exception mechanism for both policies. Tenants cannot set it (no cluster-scoped RBAC). Your platform team grants it by merging a PR against the fleet repo, which means every exception has an author, a reviewer, a timestamp, and a revert button. Railway's equivalent is "ask a workspace admin to generate the domain for you"; yours is "open a PR that labels the namespace," which scales better and leaves better evidence.
Approvals ride the PR review you already have. The approval flow needs no new tooling: the PR touching the namespace label requires platform-team review (CODEOWNERS), CI shows which routes become admissible as a result, and merge applies it. For time-boxed debugging access ("I need the TCP proxy for two hours"), pair the label with an expiry annotation and a janitor job that removes stale approvals — the number one source of permanent exceptions is temporary ones nobody cleaned up.
Every decision is auditable in two places. Denied admissions land in the Kubernetes audit log with the policy name in the responseStatus, so "who tried to expose what, when" is answerable without any add-on. Kyverno's background scans additionally write per-namespace PolicyReport objects, so kubectl get policyreport -A shows fleet-wide compliance posture at a glance — including pre-existing violations the admission policies grandfathered. Between the audit log (attempts) and PolicyReports (state), you can answer both "did anyone try?" and "is anything currently exposed that shouldn't be?"
One operational footnote from Railway worth copying: while the policies are on, make sure your own automation doesn't auto-create public surface either. Railway skips auto-generation even for admins; your equivalent is auditing controllers and GitOps defaults so no reconciler helpfully attaches new tenant namespaces to the public Gateway. The policy will catch it if one does — that is the point of enforcing below the UI — but a denial storm from your own controller is a confusing way to find out.
The takeaway
Railway's Guardrails look modest — two toggles, two sentences each — but they encode a principle every multi-tenant platform eventually learns: internal-by-default must be enforced at the API layer, because the UI is not the only client anymore. Your tenants' deploy agents submit YAML straight to the API server, and a button hidden in a dashboard never constrained them.
The Kubernetes version is two admission policies, one namespace label, and a PR-based approval flow: ValidatingAdmissionPolicy keeps tenant HTTPRoutes inside their suffix and off the public Gateway, Kyverno keeps LoadBalancer Services and TCP routes from opening raw ports, and the exposure.example.com/approved label — settable only through reviewed, revertable GitOps — is the single door between "internal" and "public." Build that, and the next time someone — human or agent — tries to quietly turn an internal service into an internet-facing endpoint, the answer is a denial with the policy name on it, not an incident.
(Railway has since added a third guardrail restricting deployments to approved GitHub organizations; the Kubernetes analogue is image signature verification, e.g. Kyverno verifyImages — same default-deny shape, different layer, and a post of its own.)
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.



