Expose a k3s Service with the rstream Kubernetes Operator
Set up a minimal k3s cluster on Ubuntu, install the rstream Kubernetes operator, deploy a small HTTP service, and expose it through an outbound-only rstream tunnel.
This guide walks through a complete first Kubernetes deployment with rstream. You will create a small single-node k3s cluster, install the rstream Kubernetes operator, deploy a tiny HTTP status service, and expose that Service through a public rstream tunnel without opening an inbound port on the machine.
The pattern is useful anywhere a Kubernetes workload runs in a private network: homelabs, remote devices, edge boxes, embedded systems, robots, drones, customer sites, lab benches, CI preview clusters, and small internal tools. The important property is the same in each case. The cluster initiates the outbound connection to rstream, while users reach the service through the rstream URL.
This is a first-run guide, but it uses the same primitives you would keep in production: Helm for installation, Kubernetes Secrets for credentials, RstreamConnection for project or engine settings, and RstreamTunnel custom resources for Service exposure.
What you will deploy
The cluster will run four layers:
| Layer | Kubernetes object | Purpose |
|---|---|---|
| Application | Deployment | Runs a small Python HTTP service. |
| Internal networking | Service | Gives the Pods a stable in-cluster address. |
| rstream connection | RstreamConnection | Stores how the namespace reaches rstream. |
| rstream exposure | RstreamTunnel | Asks the operator to expose the Service through rstream. |
The operator itself runs in rstream-system. The example application runs in rstream-demo.
Vocabulary
A Kubernetes cluster is a set of machines managed as one platform. A node is one machine in that cluster. A Pod is the smallest runnable unit and usually contains one application container. A Deployment keeps the desired number of Pods running. A Service gives those Pods a stable internal network name and port. A Secret stores sensitive data such as tokens. A Custom Resource Definition, or CRD, adds a new Kubernetes API type. An operator is a controller that watches those API types and reconciles the real cluster state toward the declared state.
In this guide, the rstream operator adds two CRDs:
RstreamConnectiondescribes how a namespace reaches rstream.RstreamTunneldescribes which Kubernetes Service should be exposed.
For hosted rstream, use projectEndpoint as the primary path. The operator calls the Control plane API, resolves the current engine for that project, and passes the resolved engine address to the tunnel agent. For self-hosted deployments without a Control plane or project model, set engine directly instead.
Prerequisites
You need an Ubuntu machine or VM with sudo, outbound internet access, and at least 2 CPUs plus 2 GB of memory. A remote Ubuntu server, a homelab host, a spare device, a cloud VM, or a local VM all work. If you want a disposable local VM, tools such as Multipass, Lima, UTM, or a normal cloud instance are fine. Start the setup commands once you are inside the Ubuntu shell.
You also need an rstream project endpoint and a token with access to that project.
Set these variables in the Ubuntu shell:
export RSTREAM_PROJECT_ENDPOINT="<project-endpoint>"
read -r -s -p "rstream token: " RSTREAM_TOKEN
echo
export RSTREAM_OPERATOR_VERSION="latest"read -s keeps the token out of the terminal output and shell history.
Install basic tools
Install the packages used by the rest of the guide:
sudo apt-get update
sudo apt-get install -y ca-certificates curl git jqjq is not required by Kubernetes, but it is useful when inspecting JSON status.
Install k3s
k3s is a compact Kubernetes distribution that works well for single-node labs, edge machines, and small devices. Install it with a kubeconfig readable by your current user:
curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown "$USER:$USER" ~/.kube/config
kubectl get nodes -o wideYou should see one node in Ready state. If it is still starting, wait a few seconds and run:
kubectl get pods -AThe system Pods should eventually become Running or Completed.
Install Helm
Helm is the package manager used to install the operator chart:
command -v helm >/dev/null 2>&1 || curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version --shortInstall the rstream operator
Clone the operator repository and install its Helm chart. The chart installs the CRDs, the controller Deployment, and the RBAC the controller needs to reconcile tunnel resources.
test -d rstream-operator || git clone https://github.com/rstreamlabs/rstream-operator.git
cd rstream-operator
git pull --ff-only
helm upgrade --install rstream-operator ./charts/rstream-operator \
--namespace rstream-system \
--create-namespace \
--set image.repository=rstream/rstream-operator \
--set image.tag="$RSTREAM_OPERATOR_VERSION"
kubectl wait --for=condition=Established \
crd/rstreamconnections.tunnels.rstream.io \
crd/rstreamtunnels.tunnels.rstream.io \
--timeout=60s
kubectl -n rstream-system rollout status deploy/rstream-operator --timeout=180sAt this point Kubernetes knows the RstreamConnection and RstreamTunnel types:
kubectl api-resources --api-group=tunnels.rstream.ioThe guide uses the explicit rstreamtunnel resource name in commands. For interactive use, the CRD also exposes tunnel, rtun, and rtunnel as aliases.
Deploy a small HTTP service
The example application is intentionally small but not tied to any special demo image. It runs Python, serves /healthz as JSON, and serves / as a tiny HTML status page that includes the Pod name. That makes it easy to confirm that the public request actually reaches the Kubernetes workload.
Apply the namespace, application ConfigMap, Deployment, and Service:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: rstream-demo
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cluster-status-app
namespace: rstream-demo
data:
server.py: |
import json
import os
import socket
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
pod = os.environ.get("HOSTNAME", socket.gethostname())
payload = {
"service": "cluster-status",
"pod": pod,
"namespace": os.environ.get("POD_NAMESPACE", "rstream-demo"),
"time": int(time.time()),
}
if self.path == "/healthz":
body = json.dumps(payload, indent=2).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
body = f"""<!doctype html>
<html>
<head><meta charset="utf-8"><title>rstream cluster status</title></head>
<body>
<h1>rstream cluster status</h1>
<p>Pod: <strong>{pod}</strong></p>
<p>Namespace: <strong>{payload["namespace"]}</strong></p>
<p><a href="/healthz">Open health JSON</a></p>
</body>
</html>
""".encode()
self.send_response(200)
self.send_header("content-type", "text/html; charset=utf-8")
self.send_header("content-length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
print("%s - %s" % (self.client_address[0], fmt % args), flush=True)
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cluster-status
namespace: rstream-demo
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: cluster-status
template:
metadata:
labels:
app.kubernetes.io/name: cluster-status
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: python:3.12-alpine
command: ["python", "/app/server.py"]
env:
- name: PYTHONDONTWRITEBYTECODE
value: "1"
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
ports:
- name: http
containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
volumeMounts:
- name: app
mountPath: /app
readOnly: true
volumes:
- name: app
configMap:
name: cluster-status-app
---
apiVersion: v1
kind: Service
metadata:
name: cluster-status
namespace: rstream-demo
spec:
selector:
app.kubernetes.io/name: cluster-status
ports:
- name: http
port: 80
targetPort: http
EOFWait for the Deployment and verify the Service from inside the cluster:
kubectl -n rstream-demo rollout status deploy/cluster-status --timeout=120s
kubectl -n rstream-demo run curl \
--rm -i --restart=Never \
--image=curlimages/curl:latest \
-- http://cluster-status/healthzIf the curl Pod prints JSON, the Kubernetes application path is healthy.
Create the rstream connection and tunnel
Store the rstream token in a Kubernetes Secret. The Secret must live in the same namespace as the RstreamConnection.
: "${RSTREAM_PROJECT_ENDPOINT:?set RSTREAM_PROJECT_ENDPOINT first}"
: "${RSTREAM_TOKEN:?set RSTREAM_TOKEN first}"
kubectl -n rstream-demo create secret generic rstream-credentials \
--from-literal=token="$RSTREAM_TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -Before applying the manifest, read it as two separate pieces.
RstreamConnection/default is the namespace-level connection profile. In hosted rstream, projectEndpoint is the normal field to set. The operator uses the token from tokenSecretRef, asks the rstream Control plane which engine currently serves that project, then stores the resolved engine in status.
RstreamTunnel/cluster-status is the actual exposure rule for the Kubernetes Service:
| Field | What it controls |
|---|---|
connectionRef.name | Which RstreamConnection to use. If omitted, the operator uses default. |
target.service.name | The Kubernetes Service to reach from the tunnel agent. |
target.service.port | The Service port, preferably by name so the tunnel survives port-number changes. |
publish | Whether rstream should return a public forwarding address. |
protocol | The protocol exposed at the rstream edge. This guide uses HTTP. |
labels | Metadata attached to the rstream tunnel inventory for filtering and operations. |
http.version | HTTP behavior at the edge. http/1.1 is the simplest default for a first service. |
Apply both resources:
cat <<EOF | kubectl apply -f -
apiVersion: tunnels.rstream.io/v1alpha1
kind: RstreamConnection
metadata:
name: default
namespace: rstream-demo
spec:
projectEndpoint: "${RSTREAM_PROJECT_ENDPOINT}"
tokenSecretRef:
name: rstream-credentials
key: token
---
apiVersion: tunnels.rstream.io/v1alpha1
kind: RstreamTunnel
metadata:
name: cluster-status
namespace: rstream-demo
spec:
connectionRef:
name: default
target:
service:
name: cluster-status
port: http
publish: true
protocol: http
labels:
app: cluster-status
environment: guide
http:
version: http/1.1
EOFThe operator now does three things. It resolves the hosted project to an engine through the Control plane, creates a dedicated tunnel-agent Deployment for RstreamTunnel/cluster-status, and waits for that agent to report the public forwarding address back into Kubernetes status.
Wait for both resources to become ready:
kubectl -n rstream-demo wait --for=condition=Ready rstreamconnection/default --timeout=120s
kubectl -n rstream-demo wait --for=condition=Ready rstreamtunnel/cluster-status --timeout=180s
kubectl -n rstream-demo get rstreamconnection,rstreamtunnelFetch the public URL and call the service through rstream:
PUBLIC_URL="$(kubectl -n rstream-demo get rstreamtunnel cluster-status -o jsonpath='{.status.forwardingAddress}')"
printf 'public URL: %s\n' "$PUBLIC_URL"
curl -fsS "$PUBLIC_URL/healthz"Opening $PUBLIC_URL in a browser should show the HTML page served by the Pod.
Inspect what the operator created
RstreamTunnel.status is the first place to look:
kubectl -n rstream-demo describe rstreamtunnel cluster-status
kubectl -n rstream-demo get rstreamtunnel cluster-status -o json | jq '.status'The operator also creates a small set of managed Kubernetes resources for the tunnel agent:
kubectl -n rstream-demo get deploy,pod,configmap,serviceaccount,role,rolebinding \
-l rstream.io/tunnel-name=cluster-statusAgent logs are useful when the tunnel is not ready:
AGENT_DEPLOYMENT="$(kubectl -n rstream-demo get rstreamtunnel cluster-status -o jsonpath='{.status.agentDeployment}')"
kubectl -n rstream-demo logs "deploy/${AGENT_DEPLOYMENT}" --tail=80This split is deliberate. The manager reconciles Kubernetes objects. The per-tunnel agent owns the data plane connection to rstream and forwards traffic to the target Service.
Add access policy after the first validation
The initial tunnel is intentionally easy to curl. For any real service, configure access policy before exposing sensitive paths. For HTTP tunnels, the operator passes the same auth settings available in the other rstream declarative formats.
For example, require rstream identity and token auth at the edge:
kubectl -n rstream-demo patch rstreamtunnel cluster-status --type=merge -p '{
"spec": {
"http": {
"version": "http/1.1",
"auth": {
"rstream": true,
"token": true
}
}
}
}'
kubectl -n rstream-demo wait --for=condition=Ready rstreamtunnel/cluster-status --timeout=180sFrom there, use the normal project access model for users and clients that should reach the tunnel.
Use a self-hosted engine instead
Hosted rstream users should prefer projectEndpoint or projectID, because the operator can resolve the current engine through the Control plane. Self-hosted deployments usually do not have a Control plane or hosted project model, so the direct engine address is the right source of truth.
The self-hosted connection shape is:
apiVersion: tunnels.rstream.io/v1alpha1
kind: RstreamConnection
metadata:
name: default
namespace: rstream-demo
spec:
engine: engine.internal.example.com:443
tokenSecretRef:
name: rstream-credentials
key: tokenprojectEndpoint, projectID, and engine are alternatives. Use exactly one of them.
Troubleshooting
If the operator Pod is in ImagePullBackOff, confirm the image tag:
kubectl -n rstream-system describe pod -l app.kubernetes.io/name=rstream-operatorIf RstreamConnection is not ready, check the project endpoint and token Secret:
kubectl -n rstream-demo describe rstreamconnection default
kubectl -n rstream-demo get secret rstream-credentialsIf RstreamTunnel is not resolved, the Service name or port is usually wrong:
kubectl -n rstream-demo get svc cluster-status -o yaml
kubectl -n rstream-demo describe rstreamtunnel cluster-statusIf the agent is not ready, inspect the managed Deployment and logs:
kubectl -n rstream-demo get deploy,pod -l rstream.io/tunnel-name=cluster-status
kubectl -n rstream-demo logs "deploy/${AGENT_DEPLOYMENT}" --tail=120If the public URL exists but curl fails, check whether HTTP auth has been enabled, whether the engine is reachable from the cluster, and whether DNS resolution works from the machine running curl.
Clean up
Delete the demo resources:
kubectl delete namespace rstream-demo
helm uninstall rstream-operator --namespace rstream-system
kubectl delete namespace rstream-systemIf this was a disposable k3s machine, remove k3s too:
sudo /usr/local/bin/k3s-uninstall.shFor a real cluster, keep the operator installed and apply the RstreamConnection plus RstreamTunnel manifests through the same GitOps or deployment path you already use for Deployments and Services.