logo
  • Core Gateway
  • Pricing
  • Install Convoy
  • Install Convoy
Blog
Tutorial

Deploying Convoy in a K8s Cluster

8 min read July 15, 2026

Written by

Motunrayo Koyejo
Motunrayo Koyejo

Product Engineer

Share

Deploying Convoy on a Kubernetes cluster

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 a LoadBalancer Service; 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 backendRefs at the convoy-server Service automatically.

1. Prerequisites

  • A running Kubernetes cluster (v1.26+ recommended).
  • kubectl and helm (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 cloud LoadBalancer is required.

Versions used in this guide:

ComponentVersion
Envoy Gatewayv1.8.2
Convoy chartconvoy/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 both ingress and gateway are enabled),
  • enables gateway but tells the chart not to create its own Gateway (create: false), since we already made convoy-gateway in Step 5,
  • points the route's parentRefs at our Gateway,
  • sets the hostname the route matches and the public host URL 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.

  1. 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"
  1. 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"
  1. Call the API through the NodePort. The Host header must match the route hostname (convoy.example.com) so the HTTPRoute matches:
curl -H "Host: convoy.example.com" "http://${NODE_IP}:${NODE_PORT}/"

Accessing the dashboard in a browser:

  • If you have DNS: point convoy.example.com at a node IP and browse to http://convoy.example.com:${NODE_PORT}.
  • Local testing without DNS: add a hosts-file entry so the browser sends the right Host header:
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.

  1. 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
  1. Update the Gateway to add a https listener (keep http for 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
  1. Update host in convoy-values.yaml to https://convoy.example.com and helm upgrade (see next section). The same HTTPRoute attaches 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.from is Same, so the HTTPRoute must live in the same namespace as the Gateway (convoy). The chart installs the route in the release namespace, so install Convoy with -n convoy as shown.
  • parentRefs in the values must exactly match the Gateway name and namespace (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 gatewayClassName defaulting to convoy-gateway-class, so create a matching GatewayClass (or override server.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}/"

Related Posts

How to Self-Host Convoy: A Complete Guide to Running Your Own Webhook Infrastructure

July 15, 2026

A step-by-step guide to self-hosting Convoy locally with Docker Compose — from cloning the repo to delivering your first webhook, with an understanding of how the pieces fit together.

Motunrayo Koyejo
Motunrayo Koyejo

Product Engineer

Webhook Security for API Providers

June 30, 2026

If you send webhooks to your customers, the security responsibility is yours. This is what to build, where it breaks in production, and what to document so your customers can receive and verify events safely.

Motunrayo Koyejo
Motunrayo Koyejo

Product Engineer

logo

2261 Market Street, San Francisco, CA 94114

Companyaccordion icon

About Us

Trust Center

Terms of Use

Privacy Policy

DPA

Productaccordion icon

Open Source

Core Gateway

Self-Hosted Premium

Private Network

Convoy Playground

Use Casesaccordion icon

Fintech

AI & Machine Learning

Developer Tools

Healthcare

SaaS Platforms

E-commerce

Logistics

IoT & Devices

Resourcesaccordion icon

API Reference

Documentation

Blog

Status Page

Roadmap

What are Webhooks?

Convoy vs. Internal Implementation

Speak to usaccordion icon

Slack

[email protected]

Connect with us

Copyright 2026, All Rights Reserved

soc stamp