Linkerd can encrypt non-HTTP TCP traffic, and it does so by treating that traffic as a generic stream of bytes, essentially making it "opaque" to the service mesh itself.

Here’s Linkerd in action, encrypting a simple TCP connection between two services, frontend and backend, running on Kubernetes.

# Deployment for the backend service
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      containers:
      - name: backend
        image: nicolaka/netshoot # A simple image with netcat
        ports:
        - containerPort: 8080
          name: tcp-backend # Linkerd needs a named port

---
# Deployment for the frontend service
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
      - name: frontend
        image: nicolaka/netshoot
        ports:
        - containerPort: 9090
          name: tcp-frontend
        command: ["sh", "-c"]
        args:
          - |
            # In the frontend, we'll act as a client.
            # We'll connect to the backend on port 8080.
            # Linkerd's proxy will intercept this and encrypt it.
            echo "Connecting to backend on port 8080..."
            nc -l -p 9090 & # Listen on our own port for demonstration
            sleep 5
            nc linkerd-proxy 8080 # Connect to the backend via the proxy
            echo "Connection established."
            sleep infinity

---
# Linkerd policy to allow and encrypt TCP traffic
apiVersion: policy.linkerd.io/v1alpha1
kind: NetworkPolicy
metadata:
  name: allow-tcp-backend
  namespace: default # Assuming your services are in the 'default' namespace
spec:
  targetRef:
    kind: Service
    name: backend
  egress:
  - to:
    - ip: {} # Allow egress to any IP
    ports:
    - port: 8080
      protocol: TCP
  ingress:
  - from:
    - selector:
        matchLabels:
          app: frontend # Allow ingress from frontend pods
    ports:
    - port: 8080
      protocol: TCP
---
# Linkerd policy to allow and encrypt TCP traffic from frontend
apiVersion: policy.linkerd.io/v1alpha1
kind: NetworkPolicy
metadata:
  name: allow-tcp-frontend
  namespace: default
spec:
  targetRef:
    kind: Service
    name: frontend
  egress:
  - to:
    - ip: {}
      ports:
      - port: 8080
        protocol: TCP
  ingress:
  - from:
    - selector:
        matchLabels:
          app: backend # Allow ingress from backend pods
    ports:
    - port: 9090
      protocol: TCP

With Linkerd installed and these resources applied, the frontend pod’s nc client will attempt to connect to backend on port 8080. Because Linkerd’s proxy is injected into both pods, it intercepts this connection. The NetworkPolicy resources tell Linkerd that TCP traffic on port 8080 (for backend) and port 9090 (for frontend) should be considered for encryption. Linkerd then establishes a TLS connection between the frontend proxy and the backend proxy, encrypting the raw TCP data flowing between them. The frontend and backend applications themselves are unaware of this encryption; they just see a normal TCP connection.

The core problem Linkerd opaque ports solve is extending the benefits of the service mesh—observability and security—to non-HTTP/gRPC traffic. Traditionally, service meshes are deeply aware of the protocols they handle, like HTTP or gRPC, allowing them to parse headers, route based on specific requests, and apply fine-grained policies. For plain TCP, this level of introspection isn’t possible. Linkerd’s opaque ports mechanism allows it to apply TLS encryption and basic L4 policy (allow/deny based on IP/port) to any TCP stream without needing to understand its contents.

When you configure Linkerd to use opaque ports for a specific TCP service, you’re essentially telling the Linkerd proxy: "Treat all traffic arriving on this port as opaque bytes, and apply mTLS encryption to it." The proxy doesn’t try to parse HTTP headers or gRPC messages; it just wraps the existing TCP stream in TLS. This is achieved through Linkerd’s NetworkPolicy resource. You define NetworkPolicy rules that specify protocol: TCP and a port. If a NetworkPolicy allows TCP traffic to a particular port, Linkerd’s proxy will automatically attempt to establish an mTLS connection for that traffic, provided the destination is also within the mesh.

The magic happens in how Linkerd’s proxy handles these connections. When a pod initiates a TCP connection to another pod within the mesh on a port designated for opaque traffic, the source proxy intercepts the outgoing connection. It then initiates an mTLS connection to the destination proxy. Once the mTLS handshake is complete, the source proxy forwards the original TCP data over the encrypted tunnel to the destination proxy, which then unwraps it and forwards it to the application. The reverse happens for incoming traffic. This entire process is transparent to the application.

The key levers you control are:

  1. NetworkPolicy Resources: These are the primary mechanism for enabling opaque ports. You create NetworkPolicy objects that target a Service and specify protocol: TCP along with the relevant port. This tells Linkerd that traffic on this port should be considered for encryption.
  2. Named Ports: The Kubernetes Service or Deployment defining the target application must have a named port that matches the port specified in the NetworkPolicy. Linkerd uses these names internally to associate policies with specific ports. For example, ports: [{ containerPort: 8080, name: tcp-backend }].
  3. linkerd.io/enable-opaque-ports Annotation (less common): While NetworkPolicy is the preferred method, you can also use the linkerd.io/enable-opaque-ports annotation on pods or namespaces to explicitly enable opaque ports for all TCP traffic to/from that pod/namespace. However, NetworkPolicy offers more granular control.

A crucial aspect of opaque ports is that Linkerd doesn’t perform any protocol-specific load balancing or routing for this traffic. It’s purely about establishing a secure, encrypted tunnel for raw TCP data. The underlying Kubernetes Service still handles the L4 load balancing to the correct pod. Linkerd’s role is to secure the connection between the client proxy and the server proxy once the destination pod is chosen.

One thing that often trips people up is the assumption that Linkerd will automatically encrypt all TCP traffic. This isn’t the case; you must explicitly opt-in for each TCP port by creating a NetworkPolicy with protocol: TCP. Without this explicit declaration, Linkerd will treat the traffic as unencrypted, even if it’s destined for a pod within the mesh. This deliberate opt-in ensures that you only encrypt the traffic you intend to, avoiding unnecessary overhead or compatibility issues with applications that might not tolerate TLS handshakes on arbitrary TCP connections.

The next step in securing your non-HTTP traffic would be to explore how to apply more advanced L4 routing rules using Linkerd’s TrafficSplit resources in conjunction with opaque ports.

Want structured learning?

Take the full Linkerd course →