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
sudoprivileges. - 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":""}
TipFor production, create a wildcard DNS record such as
*.apps.your_domainpointing toyour_server_ipand set it as the domain withkubectl patch configmap/config-domain -n knative-serving --type merge --patch '{"data":{"apps.your_domain":""}}'.
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
NoteThe in-memory channel keeps events in memory and loses them if a pod restarts. It is fine for learning and development; for production, use a persistent channel or broker such as the Kafka broker.
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.
