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.

github.com/rstreamlabs/rstream-operatorKubernetes operator, CRDs, Helm chart, samples, and runtime smoke tests.

What you will deploy

The cluster will run four layers:

LayerKubernetes objectPurpose
ApplicationDeploymentRuns a small Python HTTP service.
Internal networkingServiceGives the Pods a stable in-cluster address.
rstream connectionRstreamConnectionStores how the namespace reaches rstream.
rstream exposureRstreamTunnelAsks 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:

  • RstreamConnection describes how a namespace reaches rstream.
  • RstreamTunnel describes 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 jq

jq 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 wide

You should see one node in Ready state. If it is still starting, wait a few seconds and run:

kubectl get pods -A

The 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 --short

Install 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=180s

At this point Kubernetes knows the RstreamConnection and RstreamTunnel types:

kubectl api-resources --api-group=tunnels.rstream.io

The 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
EOF

Wait 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/healthz

If 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:

FieldWhat it controls
connectionRef.nameWhich RstreamConnection to use. If omitted, the operator uses default.
target.service.nameThe Kubernetes Service to reach from the tunnel agent.
target.service.portThe Service port, preferably by name so the tunnel survives port-number changes.
publishWhether rstream should return a public forwarding address.
protocolThe protocol exposed at the rstream edge. This guide uses HTTP.
labelsMetadata attached to the rstream tunnel inventory for filtering and operations.
http.versionHTTP 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
EOF

The 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,rstreamtunnel

Fetch 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-status

Agent 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=80

This 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=180s

From 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: token

projectEndpoint, 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-operator

If 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-credentials

If 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-status

If 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=120

If 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-system

If this was a disposable k3s machine, remove k3s too:

sudo /usr/local/bin/k3s-uninstall.sh

For 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.