
This is an end-to-end guide for deploying Convoy on a Kubernetes cluster and exposing it through the Gateway API, backed by the Envoy Gateway controller.
It walks through everything: installing the Gateway API CRDs + Envoy Gateway, creating a GatewayClass, creating a Gateway, installing the Convoy Helm chart so it wires up an HTTPRoute, and finally testing traffic.
In this guide, we expose the API through a NodePort (rather than a cloud LoadBalancer), so it works on any cluster, including bare-metal, kind, or on-prem clusters with no cloud load balancer. We configure Envoy Gateway to provision its proxy Service as type: NodePort, and at the end, we show how to reach Convoy on http://<node-ip>:<node-port>.
Just getting started? This guide covers deploying Convoy to Kubernetes. If you want to run Convoy locally first, start with How to Self-Host Convoy, our complete guide to running Convoy with Docker Compose.
Key concepts
- Envoy Gateway = the controller that watches Gateway API resources and provisions a real Envoy proxy for each
Gateway. By default, that proxy is exposed with aLoadBalancerService; in this guide, we configure it as a NodePort Service so it is reachable on every node's IP without a cloud load balancer. - GatewayClass = "which controller implements my Gateways" (points at Envoy Gateway).
- Gateway = the actual listener(s) (ports 80/443). We create this ourselves, so the name is deterministic (
convoy-gateway). - HTTPRoute = routing rules (host + path → Service). The Convoy chart generates this for us and points its
backendRefsat theconvoy-serverService automatically.
1. Prerequisites
- A running Kubernetes cluster (v1.26+ recommended).
kubectlandhelm(v3.10+) installed locally.- Cluster admin permissions (installing CRDs requires it).
- Network reachability to a node IP on the NodePort range (default
30000–32767). We expose the API via NodePort, so no cloudLoadBalanceris required.
Versions used in this guide:
| Component | Version |
|---|---|
| Envoy Gateway | v1.8.2 |
| Convoy chart | convoy/convoy (latest, app v26.3.7) |
2. Point kubectl at the right cluster
Everything below runs against the rumpty cluster. Export the kubeconfig in the shell you'll use for the whole guide:
export KUBECONFIG=~/.kube/rumpty/config
kubectl config current-context
kubectl get nodes
Heads up: Do not skip this check. The next steps install cluster-scoped CRDs and a GatewayClass, which affect the whole cluster.
3. Install Envoy Gateway (this also installs the Gateway API CRDs)
The Envoy Gateway Helm chart bundles the upstream Gateway API CRDs (GatewayClass, Gateway, HTTPRoute, …) plus the Envoy Gateway controller CRDs, and deploys the controller.
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.8.2 \
-n envoy-gateway-system \
--create-namespace
Already have the Gateway API CRDs? (e.g. installed by another controller) Install with --set crds.enabled=false to avoid conflicts.
Wait for the controller to become available:
kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway --timeout=180s
Verify the CRDs landed:
kubectl get crd | grep -E 'gateway.networking.k8s.io|gateway.envoyproxy.io'
You should see gatewayclasses, gateways, httproutes, etc.
4. Create a GatewayClass (exposed via NodePort)
By default Envoy Gateway provisions its proxy Service as type: LoadBalancer. Since we want to expose the API through a NodePort, we tell Envoy Gateway to use a NodePort Service via an EnvoyProxy resource, and attach it to the GatewayClass with parametersRef.
First, create the EnvoyProxy config that switches the proxy Service type to NodePort:
cat <<'EOF' | kubectl apply -f -
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata:
name: convoy-nodeport
namespace: envoy-gateway-system
spec:
provider:
type: Kubernetes
kubernetes:
envoyService:
type: NodePort
EOF
Now create the GatewayClass, which links your future Gateway objects to the Envoy Gateway controller and references the NodePort config above. The controller name is fixed by Envoy Gateway.
cat <<'EOF' | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: convoy-nodeport
namespace: envoy-gateway-system
EOF
Confirm it is accepted:
kubectl get gatewayclass eg
# NAME CONTROLLER ACCEPTED AGE
# eg gateway.envoyproxy.io/gatewayclass-controller True 10s
Every Gateway that uses this GatewayClass will now be fronted by a NodePort Service instead of a LoadBalancer.
5. Create the namespace and the Gateway
We create the Convoy namespace and a Gateway named convoy-gateway in it. Naming it ourselves keeps things deterministic so the chart's HTTPRoute can attach to it via parentRefs.
kubectl create namespace convoy
Start with an HTTP-only listener (we add TLS later in the Enable HTTPS (TLS) section):
cat <<'EOF' | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: convoy-gateway
namespace: convoy
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same # only HTTPRoutes in the "convoy" namespace may attach
EOF
When this Gateway is created, Envoy Gateway automatically provisions an Envoy deployment plus a Service in envoy-gateway-system. Because of the EnvoyProxy config from Step 4, that Service is a NodePort. Check the Gateway becomes PROGRAMMED=True:
kubectl -n convoy get gateway convoy-gateway
# NAME CLASS ADDRESS PROGRAMMED AGE
# convoy-gateway eg True 30s
With NodePort the Gateway ADDRESS column typically stays empty (there's no external LB IP), and that's expected. We reach it via node IP + NodePort in the Verify the route attached, then reach Convoy section.
6. Prepare the Convoy values file
Create convoy-values.yaml. This:
- disables
ingress(the chart refuses to render if bothingressandgatewayare enabled), - enables
gatewaybut tells the chart not to create its own Gateway (create: false), since we already madeconvoy-gatewayin Step 5, - points the route's
parentRefsat our Gateway, - sets the hostname the route matches and the public
hostURL Convoy advertises.
Replace convoy.example.com with your DNS name. Since we expose via NodePort, the advertised host should include the NodePort once you know it (see Step 8), for example http://convoy.example.com:31234. You can set a placeholder now and helm upgrade after you learn the port.
# convoy-values.yaml
server:
# Advertised external URL of the instance (used in links, callbacks, CORS).
# Include the NodePort once known, e.g. http://convoy.example.com:31234
env:
host: "http://convoy.example.com"
# First login only works if sign-up is enabled to create the initial user.
sign_up_enabled: true
# Turn OFF ingress, turn ON Gateway API.
ingress:
enabled: false
gateway:
enabled: true # render the HTTPRoute
create: false
hostnames:
- "convoy.example.com"
parentRefs:
- name: convoy-gateway
namespace: convoy
rules:
- matches:
- path:
type: PathPrefix
value: /
Databases: by default the chart deploys its own bundled PostgreSQL and Redis, so this quick start needs no external DB. For production, set global.externalDatabase.* / global.externalRedis.* and disable the bundled ones (postgresql.enabled=false, redis.enabled=false). See the values table in the chart README.
7. Install Convoy
Add the repo and install into the convoy namespace:
helm repo add convoy https://frain-dev.github.io/helm-charts
helm repo update
helm install convoy convoy/convoy \
-n convoy \
-f convoy-values.yaml
Watch it come up:
kubectl -n convoy get pods -w
Wait for the server (and its DB migration job) to be Running/Completed:
kubectl -n convoy rollout status deploy/convoy-server --timeout=300s
8. Verify the route attached, then reach Convoy
Confirm the chart created the HTTPRoute and it bound to the Gateway:
kubectl -n convoy get httproute
kubectl -n convoy describe httproute convoy-server | grep -A5 "Parents:"
Under Status → Parents you want Accepted: True and ResolvedRefs: True. If ResolvedRefs is False, the backend Service name/port didn't resolve (see Troubleshooting).
Reach Convoy via the NodePort
Envoy Gateway created a NodePort Service (from the EnvoyProxy config in Step 4) in the envoy-gateway-system namespace. We need two things: the NodePort that maps to the Gateway's port 80, and a node IP.
- Find the Envoy Service for your Gateway and its NodePort:
# The service name is generated as: envoy-<namespace>-<gateway-name>-<hash>
kubectl -n envoy-gateway-system get svc -l gateway.envoyproxy.io/owning-gateway-name=convoy-gateway
Grab the NodePort that maps to port 80 (usually in the 30000–32767 range):
NODE_PORT=$(kubectl -n envoy-gateway-system get svc \
-l gateway.envoyproxy.io/owning-gateway-name=convoy-gateway \
-o jsonpath='{.items[0].spec.ports[?(@.port==80)].nodePort}')
echo "NodePort: $NODE_PORT"
- Get an externally reachable node IP:
# Prefer ExternalIP; fall back to InternalIP for private/on-prem clusters.
NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="ExternalIP")].address}')
[ -z "$NODE_IP" ] && NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
echo "Node IP: $NODE_IP"
- Call the API through the NodePort. The
Hostheader must match the route hostname (convoy.example.com) so theHTTPRoutematches:
curl -H "Host: convoy.example.com" "http://${NODE_IP}:${NODE_PORT}/"
Accessing the dashboard in a browser:
- If you have DNS: point
convoy.example.comat a node IP and browse tohttp://convoy.example.com:${NODE_PORT}. - Local testing without DNS: add a hosts-file entry so the browser sends the right
Hostheader:
echo "${NODE_IP} convoy.example.com" | sudo tee -a /etc/hosts
then open http://convoy.example.com:${NODE_PORT} and create your first admin user (sign-up is enabled in the values above).
Tip: make sure server.env.host in convoy-values.yaml includes the NodePort (e.g. http://convoy.example.com:31234) so links and callbacks Convoy generates point at the right port. Update it and helm upgrade if needed.
9. (Optional) Enable HTTPS (TLS)
To terminate TLS at the Gateway, add an HTTPS listener referencing a TLS Secret.
- Create the TLS secret (bring your own cert, or use cert-manager to generate one):
kubectl -n convoy create secret tls convoy-tls \
--cert=tls.crt --key=tls.key
- Update the Gateway to add a
httpslistener (keephttpfor redirects/ACME):
cat <<'EOF' | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: convoy-gateway
namespace: convoy
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: convoy-tls
allowedRoutes:
namespaces:
from: Same
EOF
- Update
hostinconvoy-values.yamltohttps://convoy.example.comandhelm upgrade(see next section). The sameHTTPRouteattaches to both listeners.
Automatic HTTP→HTTPS redirect can be added with an Envoy Gateway HTTPRoute filter (RequestRedirect to https). This is optional and out of scope here.
10. Upgrading/changing config
Edit convoy-values.yaml and re-apply:
helm upgrade convoy convoy/convoy -n convoy -f convoy-values.yaml
11. Troubleshooting
Both ingress and gateway are enabled. Please enable only one. The chart fails template rendering if both are on. Ensure server.ingress.enabled: false when server.gateway.enabled: true.
HTTPRoute shows Accepted: False / route not attaching
- The Gateway's
allowedRoutes.namespaces.fromisSame, so theHTTPRoutemust live in the same namespace as the Gateway (convoy). The chart installs the route in the release namespace, so install Convoy with-n convoyas shown. parentRefsin the values must exactly match the Gatewaynameandnamespace(convoy-gateway/convoy).
HTTPRoute ResolvedRefs: False The route's backendRefs (auto-set to the convoy-server Service) couldn't resolve. Check the service exists and the port matches:
kubectl -n convoy get svc convoy-server
Gateway ADDRESS is empty Expected with NodePort, since there is no external LB IP. Reach Convoy on http://<node-ip>:<node-port> as in Step 8.
Can't reach the NodePort from outside Ensure the node's firewall/security group allows inbound traffic on the NodePort range (30000–32767), and that you're using a node IP that's actually routable from your machine (ExternalIP for cloud nodes, a reachable InternalIP for on-prem).
404 / no route matched The Host header (or DNS name) must match server.gateway.hostnames. Test with an explicit -H "Host: convoy.example.com" header.
Envoy proxy not created after Gateway apply Check the controller logs:
kubectl -n envoy-gateway-system logs deploy/envoy-gateway
12. Uninstall/cleanup
helm uninstall convoy -n convoy
kubectl -n convoy delete gateway convoy-gateway
kubectl delete gatewayclass eg
kubectl -n envoy-gateway-system delete envoyproxy convoy-nodeport
helm uninstall eg -n envoy-gateway-system
kubectl delete namespace convoy envoy-gateway-system
# CRDs are cluster-scoped and NOT removed by helm uninstall. Only delete these if no
# other controller uses the Gateway API:
# kubectl get crd | grep -E 'gateway.networking.k8s.io|gateway.envoyproxy.io'
Appendix A: Let the chart create the Gateway instead
If you prefer the chart to also create the Gateway (instead of Step 5), set server.gateway.create: true. Note two caveats:
- The chart-created Gateway uses
gatewayClassNamedefaulting toconvoy-gateway-class, so create a matchingGatewayClass(or overrideserver.gateway.gatewayClassName: eg). - The generated Gateway is named after the server fullname, and it only defines an HTTP :80 listener with
allowedRoutes.from: Same. For custom listeners/TLS, managing the Gateway yourself (Step 5) is more flexible.
server:
ingress:
enabled: false
gateway:
enabled: true
create: true
gatewayClassName: eg
hostnames:
- "convoy.example.com"
# parentRefs must point to the chart-created Gateway; verify its name with:
# kubectl -n convoy get gateway
Appendix B: Quick command recap
export KUBECONFIG=~/.kube/rumpty/config
# 1. Envoy Gateway + Gateway API CRDs
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.8.2 -n envoy-gateway-system --create-namespace
kubectl -n envoy-gateway-system rollout status deploy/envoy-gateway --timeout=180s
# 2. EnvoyProxy (NodePort) + GatewayClass referencing it
kubectl apply -f - <<'EOF'
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyProxy
metadata: { name: convoy-nodeport, namespace: envoy-gateway-system }
spec:
provider:
type: Kubernetes
kubernetes:
envoyService: { type: NodePort }
---
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata: { name: eg }
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
parametersRef:
group: gateway.envoyproxy.io
kind: EnvoyProxy
name: convoy-nodeport
namespace: envoy-gateway-system
EOF
# 3. Namespace + Gateway
kubectl create namespace convoy
kubectl apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: convoy-gateway, namespace: convoy }
spec:
gatewayClassName: eg
listeners:
- { name: http, protocol: HTTP, port: 80, allowedRoutes: { namespaces: { from: Same } } }
EOF
# 4. Convoy (uses convoy-values.yaml from Step 6)
helm repo add convoy https://frain-dev.github.io/helm-charts && helm repo update
helm install convoy convoy/convoy -n convoy -f convoy-values.yaml
# 5. Access via NodePort
NODE_PORT=$(kubectl -n envoy-gateway-system get svc \
-l gateway.envoyproxy.io/owning-gateway-name=convoy-gateway \
-o jsonpath='{.items[0].spec.ports[?(@.port==80)].nodePort}')
NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="ExternalIP")].address}')
[ -z "$NODE_IP" ] && NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
curl -H "Host: convoy.example.com" "http://${NODE_IP}:${NODE_PORT}/"
