Knative adds serverless primitives to Kubernetes: you deploy a container as a Knative Service, and Knative gives it a URL, scales it with request load (down to zero pods when idle), and keeps every change as an immutable revision you can route traffic to. In this tutorial you will build a single-node Kubernetes cluster with k3s on Ubuntu 24.04, install Knative Serving with the Kourier ingress, deploy a service that scales to zero, split traffic between two revisions, and finish with Knative Eventing routing events to a service.

Prerequisites

To follow this tutorial, you need:

  • A server running Ubuntu 24.04 LTS with a public IPv4 address, for example a CubePath VPS. This tutorial uses a single node.
  • At least 4 vCPUs, 8 GB of RAM and 30 GB of disk. The Knative project recommends 6 CPUs for a single-node installation that runs real workloads.
  • A non-root user with sudo privileges.
  • Ports 22 and 80 reachable from your workstation.

Throughout the guide, replace your_server_ip with the public IPv4 address of the server.

Step 1 - Installing k3s without Traefik

k3s is a lightweight, certified Kubernetes distribution that installs as a single binary. It ships with Traefik as its ingress controller, but Knative brings its own networking layer (Kourier) that needs ports 80 and 443 on the node, so you will install k3s with Traefik disabled.

Download the k3s install script and review it before running it:

curl -sfL https://get.k3s.io -o k3s-install.sh
less k3s-install.sh

Run the installer, passing --disable traefik to the k3s server:

sudo sh k3s-install.sh --disable traefik

The script installs the k3s binary, a kubectl symlink and a systemd unit called k3s. Copy the cluster credentials to your user so you can run kubectl without sudo:

mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown "$USER":"$USER" ~/.kube/config
chmod 600 ~/.kube/config
echo 'export KUBECONFIG=$HOME/.kube/config' >> ~/.bashrc
source ~/.bashrc

Confirm that the node is ready:

kubectl get nodes
NAME        STATUS   ROLES                  AGE   VERSION
knative01   Ready    control-plane,master   40s   v1.34.1+k3s1

If UFW is active, allow SSH, HTTP and the k3s pod and service networks, as recommended by the k3s documentation:

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow from 10.42.0.0/16 to any
sudo ufw allow from 10.43.0.0/16 to any

Step 2 - Installing Knative Serving

Knative Serving is published as plain YAML manifests attached to each GitHub release. Store the release in a variable so every command uses the same version. This tutorial uses knative-v1.23.0; check the Knative Serving releases page for a newer one:

export KNATIVE_VERSION=knative-v1.23.0

Install the custom resource definitions first, then the core components:

kubectl apply -f https://github.com/knative/serving/releases/download/${KNATIVE_VERSION}/serving-crds.yaml
kubectl apply -f https://github.com/knative/serving/releases/download/${KNATIVE_VERSION}/serving-core.yaml

Wait until all Serving deployments are available:

kubectl wait --for=condition=Available deployment --all -n knative-serving --timeout=300s
kubectl get pods -n knative-serving
NAME                          READY   STATUS    RESTARTS   AGE
activator-6d9c95b9b6-2kq7x    1/1     Running   0          70s
autoscaler-7c4b8d8f6b-xm4lp   1/1     Running   0          70s
controller-5f8d9d6b7c-wq9vn   1/1     Running   0          70s
webhook-7b6f8d8c9d-4hjzt      1/1     Running   0          70s

Step 3 - Installing Kourier and configuring DNS

Knative needs a networking layer to route external requests to revisions and to the activator when a service is scaled to zero. Kourier is the lightest option and is maintained by the Knative project.

Install Kourier and make it the default ingress class for Knative:

kubectl apply -f https://github.com/knative-extensions/net-kourier/releases/download/${KNATIVE_VERSION}/kourier.yaml

kubectl patch configmap/config-network \
  --namespace knative-serving \
  --type merge \
  --patch '{"data":{"ingress-class":"kourier.ingress.networking.knative.dev"}}'

Kourier creates a LoadBalancer service. On k3s, the built-in ServiceLB binds it to ports 80 and 443 of the node, so the external IP is your server's address:

kubectl get service kourier -n kourier-system
NAME      TYPE           CLUSTER-IP     EXTERNAL-IP      PORT(S)                      AGE
kourier   LoadBalancer   10.43.211.54   your_server_ip   80:31080/TCP,443:32443/TCP   45s

If EXTERNAL-IP stays <pending>, something else is already using port 80 on the node (usually Traefik). See the troubleshooting section.

Every Knative Service gets a hostname of the form service.namespace.domain. For testing, Knative provides a job that sets the domain to your_server_ip.sslip.io, a public wildcard DNS service that resolves any *.your_server_ip.sslip.io name to your_server_ip:

kubectl apply -f https://github.com/knative/serving/releases/download/${KNATIVE_VERSION}/serving-default-domain.yaml

After a few seconds, check the configured domain:

kubectl get configmap config-domain -n knative-serving -o jsonpath='{.data}'; echo
{"your_server_ip.sslip.io":""}

Step 4 - Installing the kn CLI

kn is the Knative command-line client. It creates services, revisions, traffic splits and triggers without writing YAML by hand. Download the binary from the same release as the cluster components:

curl -fsSLo kn https://github.com/knative/client/releases/download/${KNATIVE_VERSION}/kn-linux-amd64
sudo install -m 0755 kn /usr/local/bin/kn
rm kn

On an ARM server, download kn-linux-arm64 instead. Verify the installation:

kn version
Version:      v1.23.0
Build Date:   ...
Git Revision: ...

Step 5 - Deploying your first Knative Service

Deploy the Knative sample application, a small Go web server that prints Hello followed by the value of the TARGET environment variable:

kn service create hello \
  --image ghcr.io/knative/helloworld-go:latest \
  --port 8080 \
  --env TARGET=World

kn waits until the first revision is ready and prints the service URL:

Service 'hello' created to latest revision 'hello-00001' is available at URL:
http://hello.default.your_server_ip.sslip.io

Behind that single command, Knative created a Configuration, a Revision (hello-00001), a Route and a Kubernetes Deployment. Send a request to the URL:

curl http://hello.default.your_server_ip.sslip.io
Hello World!

Step 6 - Watching scale to zero

By default, Knative removes the last pod of a revision when it has received no traffic for the stable window (60 seconds) plus a short grace period. Open a second SSH session and watch the pods of the service:

kubectl get pods -l serving.knative.dev/service=hello --watch

After about a minute and a half without requests, the pod goes to Terminating and disappears. Now send another request from the first session:

time curl http://hello.default.your_server_ip.sslip.io

The request is held by the Knative activator while a new pod starts, so this first response takes one or two seconds longer than the next ones. That delay is the cold start.

You control scaling per revision. For example, keep one pod always warm to avoid cold starts and cap the service at five pods:

kn service update hello --scale-min 1 --scale-max 5

This creates revision hello-00002 with the annotations autoscaling.knative.dev/min-scale: "1" and autoscaling.knative.dev/max-scale: "5". Restore scale to zero with:

kn service update hello --scale-min 0

Cluster-wide defaults live in the config-autoscaler ConfigMap. For example, to wait 60 seconds instead of 30 before removing the last pod:

kubectl patch configmap/config-autoscaler \
  --namespace knative-serving \
  --type merge \
  --patch '{"data":{"scale-to-zero-grace-period":"60s"}}'

Step 7 - Splitting traffic between revisions

Every change to a service creates a new revision, and by default the latest ready revision receives 100% of the traffic. Change the environment variable to produce a new version of the application:

kn service update hello --env TARGET=Knative
kn revisions list
NAME          SERVICE   TRAFFIC   TAGS   GENERATION   AGE   CONDITIONS   READY   REASON
hello-00004   hello     100%             4            10s   4 OK / 4     True
hello-00003   hello                      3            2m    3 OK / 4     True
hello-00002   hello                      2            4m    3 OK / 4     True
hello-00001   hello                      1            6m    3 OK / 4     True

Send half of the requests to the original revision and half to the latest one, a typical canary release:

kn service update hello \
  --traffic hello-00001=50 \
  --traffic @latest=50

Make a few requests and you will see both responses:

for i in 1 2 3 4 5 6; do curl -s http://hello.default.your_server_ip.sslip.io; done
Hello World!
Hello Knative!
Hello Knative!
Hello World!
Hello World!
Hello Knative!

Tags give a revision its own URL, so you can test it directly even when it receives 0% of the traffic:

kn service update hello --tag hello-00001=v1
curl http://v1-hello.default.your_server_ip.sslip.io
Hello World!

When you are happy with the new version, send all traffic to it:

kn service update hello --traffic @latest=100

Step 8 - Managing services as YAML

kn is convenient interactively, but in a CI/CD pipeline you usually keep the service definition in Git and apply it with kubectl. Create a manifest for a second service:

nano greeter.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: greeter
  namespace: default
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/min-scale: "0"
        autoscaling.knative.dev/max-scale: "10"
        autoscaling.knative.dev/target: "50"
    spec:
      containerConcurrency: 0
      timeoutSeconds: 60
      containers:
        - image: ghcr.io/knative/helloworld-go:latest
          ports:
            - containerPort: 8080
          env:
            - name: TARGET
              value: "from YAML"
          resources:
            requests:
              cpu: 100m
              memory: 64Mi
            limits:
              memory: 256Mi

The autoscaling.knative.dev/target annotation sets the number of concurrent requests per pod that the autoscaler aims for. Apply the manifest and check the service:

kubectl apply -f greeter.yaml
kubectl get ksvc greeter
NAME      URL                                                LATESTCREATED   LATESTREADY     READY   REASON
greeter   http://greeter.default.your_server_ip.sslip.io     greeter-00001   greeter-00001   True

Each time the pipeline changes the image tag in this file and runs kubectl apply, Knative creates a new revision and shifts traffic to it once it is ready.

Step 9 - Installing Knative Eventing

Knative Eventing delivers events in the CloudEvents format from producers to consumers through brokers and triggers. Install the CRDs, the core components, the in-memory channel and the channel-based broker:

kubectl apply -f https://github.com/knative/eventing/releases/download/${KNATIVE_VERSION}/eventing-crds.yaml
kubectl apply -f https://github.com/knative/eventing/releases/download/${KNATIVE_VERSION}/eventing-core.yaml
kubectl apply -f https://github.com/knative/eventing/releases/download/${KNATIVE_VERSION}/in-memory-channel.yaml
kubectl apply -f https://github.com/knative/eventing/releases/download/${KNATIVE_VERSION}/mt-channel-broker.yaml

Wait for the components to become available:

kubectl wait --for=condition=Available deployment --all -n knative-eventing --timeout=300s

Create a broker called default in the default namespace:

nano broker.yaml
apiVersion: eventing.knative.dev/v1
kind: Broker
metadata:
  name: default
  namespace: default
kubectl apply -f broker.yaml
kubectl get broker default
NAME      URL                                                                        AGE   READY   REASON
default   http://broker-ingress.knative-eventing.svc.cluster.local/default/default   10s   True

Deploy a consumer that prints every event it receives. --scale-min 1 keeps it running so you can follow its logs:

kn service create event-display \
  --image gcr.io/knative-releases/knative.dev/eventing/cmd/event_display \
  --scale-min 1

Create a trigger that sends only events of type com.example.order.created to that service:

kn trigger create order-created \
  --broker default \
  --filter type=com.example.order.created \
  --sink ksvc:event-display

Send a CloudEvent to the broker from a temporary pod inside the cluster:

kubectl run curl --image=curlimages/curl --rm -it --restart=Never -- \
  -sS -o /dev/null -w "%{http_code}\n" \
  -X POST http://broker-ingress.knative-eventing.svc.cluster.local/default/default \
  -H "Ce-Id: 1001" \
  -H "Ce-Specversion: 1.0" \
  -H "Ce-Type: com.example.order.created" \
  -H "Ce-Source: /orders" \
  -H "Content-Type: application/json" \
  -d '{"orderId":"123","amount":99.99}'

The broker answers 202, which means it accepted the event. Check the consumer logs:

kubectl logs -l serving.knative.dev/service=event-display -c user-container --tail=20
Context Attributes,
  specversion: 1.0
  type: com.example.order.created
  source: /orders
  id: 1001
  datacontenttype: application/json
Extensions,
  knativearrivaltime: 2026-09-25T10:15:02.12Z
Data,
  {
    "orderId": "123",
    "amount": 99.99
  }

Send another event with a different Ce-Type and it will not reach event-display, because the trigger filter drops it.

Troubleshooting

The Kourier service shows EXTERNAL-IP <pending>. ServiceLB could not bind ports 80 and 443 on the node. Check that Traefik is not running with kubectl get pods -n kube-system | grep traefik, and that no other process listens on those ports with sudo ss -tlnp | grep -E ':80 |:443 '. If you installed k3s with Traefik, rerun the installer with --disable traefik.

A service stays READY False or Unknown. Describe it and read the conditions, which usually point to an image pull error or a crashing container:

kubectl describe ksvc hello
kubectl get pods -l serving.knative.dev/service=hello
kubectl logs -n knative-serving deployment/controller --tail=50

curl returns 404 Not Found from Kourier. The Host header does not match any Knative route. Use the exact URL from kn service list, or test with curl -H "Host: hello.default.your_server_ip.sslip.io" http://your_server_ip.

The sslip.io hostname does not resolve. Some resolvers block wildcard DNS services. Test with dig +short hello.default.your_server_ip.sslip.io, and use the Host header approach above or configure a real wildcard domain.

Conclusion

You now have Knative Serving running on k3s, with services that get a URL automatically, scale to zero when idle, and keep immutable revisions you can split traffic between, plus Knative Eventing delivering filtered CloudEvents to a service. As next steps, configure a real wildcard domain and enable HTTPS with cert-manager, move from the in-memory channel to the Kafka broker for durable events, and deploy your services from a CI/CD pipeline with kubectl apply.