Building Secure Self-Hosted Connectors with TLS, Traefik, and Kubernetes
TRTim Reber • • 4 min read

Building Secure Self-Hosted Connectors with TLS, Traefik, and Kubernetes

Self-hosted workloads need a reliable way to connect back to the control plane. For Shiper, that connection carries an SSH-based connector protocol, so it needs to be reachable over TCP while still being protected by TLS and routed to the correct tenant.

This post describes how the connector works in production and how users connect a server to Shiper.

Using a self-hosted connector

Create a self-hosted instance in the Shiper dashboard. The dashboard provides an API key and the Docker command for that instance. Run the command on the server where deployments should run:

docker run -d \
--name shiper-connector \
--restart always \
-e API_KEY=YOUR_KEY \
-v /var/run/docker.sock:/var/run/docker.sock \
shiperapp/connector:latest

The agent connects outbound to Shiper, so the server does not need an inbound firewall rule. Once the instance appears as Connected in the dashboard, select it under Use Self-Hosted Instance when configuring a project. Builds continue to run in Shiper's infrastructure; the resulting application container is deployed through the connector to your server.

The connection model

Each connector runs as a Kubernetes workload and listens on port 3454. The public hostname is unique to the connector:

<connector-id>.connector.shiper.app

The client connects to that hostname using TLS. Traefik terminates neither TLS nor SSH; it reads the TLS SNI value and forwards the encrypted stream to the matching connector service. The connector server then performs the TLS and SSH handshake itself.

Docker agent
│
│ TLS + SNI: <connector-id>.connector.shiper.app
▼
Load balancer
│ TCP 3454
▼
Traefik connector entrypoint
│ IngressRouteTCP + TLS passthrough
▼
Connector service
│
▼
Connector server

Using SNI gives every connector an isolated route without requiring a separate public port for each instance.

TLS inside the connector

The connector server loads its certificate and private key from a Kubernetes Secret mounted at /certs:

/certs/tls.crt
/certs/tls.key

The server wraps the incoming connection with TLS before handing it to the SSH server. The client follows the same order in reverse: it establishes TLS with the connector hostname and then opens the SSH session over that encrypted connection.

This detail matters. A plain TCP client, an SSH client that skips TLS, or a client using the wrong SNI hostname will not complete the handshake. Depending on where the connection stops, the symptom can be as vague as TLS handshake error: EOF.

Issuing certificates with cert-manager

Every connector receives a certificate whose DNS name matches its route:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: connector-example
spec:
secretName: connector-example
issuerRef:
name: connector-tls
kind: ClusterIssuer
dnsNames:
- example.connector.shiper.app

cert-manager stores the resulting certificate and private key in the Secret mounted by the connector. The certificate authority can be selected according to the environment and trust requirements.

Routing encrypted TCP with Traefik

The connector route is an IngressRouteTCP with TLS passthrough enabled:

apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: connector-example
spec:
entryPoints:
- connector
routes:
- match: HostSNI(`example.connector.shiper.app`)
services:
- name: connector-example-svc
port: 3454
tls:
passthrough: true

Traefik needs a TCP entrypoint for port 3454, and its Kubernetes CRD provider must be enabled. Without the CRD provider, the resource can exist successfully in Kubernetes while Traefik silently ignores it.

In production, the load balancer exposes the connector entrypoint on port 3454 and forwards it to Traefik.

DNS is part of the routing configuration

The wildcard record must point connector hostnames at the production load balancer:

*.connector.shiper.app -> production load balancer

The load balancer must preserve TCP traffic and forward port 3454 to Traefik. HTTP routing rules do not apply to this connection. The hostname in all three places must agree:

  1. The connector's certificate DNS name.
  2. The HostSNI value in IngressRouteTCP.
  3. The hostname used by the client for TLS and SSH.

A mismatch in any one of them can produce a connection that reaches the load balancer but never reaches the intended connector.

Before and after

The connector used to connect directly to a NodePort on a single Kubernetes node. That made the first deployment easy to understand, but it bypassed the production load balancer and Traefik's routing layer.

Before

After

For customers, the important change is that the server remains in their environment while the connection management moves into Shiper. The connector identifies the correct server using its instance-specific hostname, and the encrypted channel is carried through the production load balancer and Traefik without exposing application containers directly.

The result for customers

After the agent reports Connected, the workflow is the same as a regular Shiper deployment:

  1. Create or open a project.
  2. Enable Use Self-Hosted Instance.
  3. Select the connected server.
  4. Deploy as usual.

Shiper builds the application, sends the deployment through the secure connector, and starts the resulting container on the selected server. Domains, environment variables, and project settings continue to be managed through Shiper, while the workload runs on infrastructure controlled by the customer.

The final design keeps the connector protocol behind a TLS-protected TCP entrypoint while preserving tenant-level routing through SNI. Customers only need Docker, the command generated by the dashboard, and outbound network access from their server.