termal.in

Connector · Docker

Run the Connector in a container — no inbound port.

The official image ghcr.io/termalinapp/termalin-connector runs the Termalin Connector in one container on any machine of a network — a NAS, a home lab, a router, a Kubernetes cluster, an ECS service. It dials out to Termalin and reaches the servers, internal sites and desktops on that network, with no inbound port open. About 4 MB, amd64/arm64, runs as uid 10001.

amd64 / arm64 · no inbound port · Pro (or 14-day trial)

01  /  why a container

One container on the network, nothing to open.

A Connector lets Termalin reach a private box with no inbound port and no long-lived key on it. Some machines are easier to add as a container than to install a binary and a service on — and some can only run containers. The image fits them:

  • ▸A NAS or home lab. Synology, Unraid or a Docker host on your LAN — reach the box’s own SSH, and any other server, site or desktop on the network.
  • ▸A router. MikroTik RouterOS 7 runs containers; one holds the Connector for the router and everything behind it.
  • ▸Kubernetes. A one-replica Deployment reaches cluster services by their internal address.
  • ▸AWS ECS / a VPC. A Fargate or EC2 task with outbound HTTPS only — no load balancer, no inbound rule — reaches private instances, internal sites and desktops.

02  /  the image

Four megabytes, no shell, no SSH server.

The image is ghcr.io/termalinapp/termalin-connector:<version> (and :latest), for linux/amd64 and linux/arm64.

  • ▸About 4 MB. The static Connector, CA certificates and nothing else — no shell, no package manager, no SSH server. It runs as uid 10001 and keeps its config in the /data volume.
  • ▸Built in the open. In the public termalinapp/termalin repository, only from the signed release binaries on termal.in — each checked against the release manifest and its signature before it goes in.
  • ▸armv7 is not built yet (32-bit ARM). amd64 and arm64 only for now.

03  /  quick start

Enrol a container in one docker run.

Get an enrolment token in the cabinet (Connectors → Add), then:

docker run -d --name termalin --restart unless-stopped \
  -v termalin:/data \
  -e TERMALIN_TOKEN=<token> \
  -e TERMALIN_TARGET=192.168.1.10:22 \
  -e TERMALIN_USER=deploy \
  ghcr.io/termalinapp/termalin-connector

The variables:

VariableWhat it does
TERMALIN_TOKENEnrolment token; used only on the first start, when the volume has no config yet.
TERMALIN_TARGEThost:port of the SSH server that terminals, commands and files go to (the Docker host, or another machine). Inside the container 127.0.0.1 is the container itself, which has no SSH server.
TERMALIN_USERLogin user on that SSH server (default: the account’s default).
TERMALIN_NAMEName in the cabinet (default: the container’s hostname — set --hostname or this).
TERMALIN_SERVERDefault https://termal.in.

First start: with no config in /data and a token set, the container enrols, prints the sign-in line below and connects. Later starts just connect — the token may stay in the environment, it is not sent again. With no config and no token it says so and exits 1. docker stop stops it at once. To enrol again, remove the volume and start with a new token.

04  /  the sign-in line

Trust the certificate on the target.

The container’s Connector never touches the target. At enrolment it prints the line that lets Termalin’s short-lived certificates for this account sign in — and docker exec termalin termalin-agent check prints it again:

cert-authority,principals="termalin-<account id>" ssh-ed25519 AAAA… termalin-connector

Put it into ~/.ssh/authorized_keys of the login user on the target (one line, file mode 600, ~/.ssh 700). On a server you manage as root you can instead add TrustedUserCAKeys and AuthorizedPrincipalsFile to its sshd_config — check prints both. Without this, terminals and commands through this Connector are refused by the target; web links and desktops don’t need it. check also reports whether the target’s SSH server answers (sshd=up); the sign-in status is ca=unknown, because the target’s files can’t be read from inside the container.

05  /  no SSH server needed

Internal sites and desktops, with no sshd anywhere.

Internal sites and remote desktops (RDP, VNC) don’t go through SSH: the backend asks the Connector to open a connection to the site’s or desktop’s own host:port, and the Connector connects there directly. So a container in a VPC, an ECS service or a home network can publish internal sites and desktops with no SSH server at all. The database page with a database password works the same way, to the database’s port.

  • ▸Private addresses only. Dial targets must be on the private network — RFC 1918, loopback, link-local, CGNAT 100.64/10 (such as Tailscale), IPv6 ULA. The Connector is not a relay to the internet, and cloud metadata addresses (169.254.169.254 and the like) are always refused.
  • ▸Needs an SSH server on the target: the terminal, ssh_exec and the other command tools, files over SFTP, the passwordless database tools, metrics and backups. Set TERMALIN_TARGET and add the sign-in line there.

06  /  docker compose

The same, as a compose file.

services:
  termalin:
    image: ghcr.io/termalinapp/termalin-connector:latest
    restart: unless-stopped
    hostname: office-nas
    environment:
      TERMALIN_TOKEN: ${TERMALIN_TOKEN}
      TERMALIN_TARGET: 192.168.1.10:22
      TERMALIN_USER: deploy
    volumes:
      - termalin:/data
volumes:
  termalin:

To reach the Docker host itself, use its LAN address, or on Docker Desktop host.docker.internal:22 (on Linux add extra_hosts: ["host.docker.internal:host-gateway"]).

07  /  NAS

Synology and Unraid.

  • ▸Synology (Container Manager): Registry → add ghcr.io/termalinapp/termalin-connector, create a container with the variables above, a volume for /data, “auto restart” on. Enable SSH in Control Panel → Terminal & SNMP for the terminal, and set TERMALIN_TARGET=<NAS address>:22.
  • ▸Unraid: Docker → Add Container, repository ghcr.io/termalinapp/termalin-connector, a path /data → /mnt/user/appdata/termalin, the variables as container variables.
  • ▸A bind-mounted folder must be writable by uid 10001 (chown 10001:10001 /mnt/…/termalin); a named volume is set up by Docker for you.

08  /  MikroTik RouterOS 7

On the router itself.

arm64 and x86 routers for now (no armv7 build yet). Enable the container package and device-mode, then:

/interface/veth add name=veth-termalin address=172.17.0.2/24 gateway=172.17.0.1
/interface/bridge/port add bridge=containers interface=veth-termalin
/container/envs add name=termalin key=TERMALIN_TOKEN value=<token>
/container/envs add name=termalin key=TERMALIN_TARGET value=192.168.88.1:22
/container/mounts add name=termalin-data src=/usb1/termalin dst=/data
/container add remote-image=ghcr.io/termalinapp/termalin-connector:latest \
    interface=veth-termalin envlist=termalin mounts=termalin-data \
    root-dir=/usb1/termalin-root start-on-boot=yes logging=yes

That assumes a bridge containers with 172.17.0.1/24 and NAT out, as in MikroTik’s own container guide. Target the router’s own SSH (/ip/service enable ssh) or any host behind it.

09  /  Kubernetes

One replica, a Secret, a tiny PVC.

apiVersion: v1
kind: Secret
metadata: { name: termalin }
stringData: { token: "<token>" }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: termalin-data }
spec:
  accessModes: [ReadWriteOnce]
  resources: { requests: { storage: 16Mi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: termalin-connector }
spec:
  replicas: 1
  strategy: { type: Recreate }
  selector: { matchLabels: { app: termalin-connector } }
  template:
    metadata: { labels: { app: termalin-connector } }
    spec:
      securityContext: { runAsUser: 10001, runAsGroup: 10001, fsGroup: 10001, runAsNonRoot: true }
      containers:
        - name: connector
          image: ghcr.io/termalinapp/termalin-connector:0.2.3
          env:
            - { name: TERMALIN_TOKEN, valueFrom: { secretKeyRef: { name: termalin, key: token } } }
            - { name: TERMALIN_TARGET, value: "10.0.12.7:22" }
            - { name: TERMALIN_NAME, value: "k8s-prod" }
          securityContext: { readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities: { drop: [ALL] } }
          resources: { requests: { cpu: 10m, memory: 16Mi }, limits: { memory: 64Mi } }
          volumeMounts: [{ name: data, mountPath: /data }]
      volumes:
        - { name: data, persistentVolumeClaim: { claimName: termalin-data } }

Internal services are reached by their cluster address in a web link — http://grafana.monitoring.svc:3000 resolves through the cluster DNS to a private address. Keep one replica per enrolment: two pods on the same volume would be the same Connector twice.

10  /  AWS ECS

A Fargate or EC2 task, outbound only.

A service with desired count 1, the image above, the token from Secrets Manager as TERMALIN_TOKEN, and an EFS volume at /data (owned by 10001 — an EFS access point with uid/gid 10001). Outbound HTTPS to termal.in is all it needs — no load balancer, no inbound rule. The security groups of the targets must let the task in on the ports used (22, 3389, the sites’ ports). The ECS task metadata address is refused like every metadata address.

11  /  updates and limits

Pull a new tag; what a container can’t do.

  • ▸Updates. The container never updates itself — pull a new tag and recreate it (docker compose pull && docker compose up -d, or a new image tag in the Deployment). The volume keeps the enrolment. The cabinet shows no “Update now” for it.
  • ▸SSH features need a target. Terminal, commands, files, passwordless databases, metrics and backups need an SSH server on the target and the sign-in line there; the image has none of its own.
  • ▸One target SSH server per Connector (TERMALIN_TARGET); web links and desktops can name any private address.
  • ▸armv7 (32-bit ARM) is not built yet.

Questions

Which architectures and how big is the image?

A multi-arch image for linux/amd64 and linux/arm64, about 4 MB: the static Connector, CA certificates and nothing else — no shell, no package manager, no SSH server. It runs as uid 10001 and keeps its config in the /data volume. armv7 (32-bit ARM) is not built yet.

Where does the image come from?

It is built in the open, in the public termalinapp/termalin repository, only from the signed release binaries published on termal.in — each one checked against the release manifest and its signature before it goes into the image.

Do I need an SSH server in or next to the container?

Not for internal sites, remote desktops (RDP/VNC) or the database page — those go straight to the target’s own host:port through the Connector, so a container in a VPC or a home network can publish them with no sshd anywhere. A terminal, commands, SFTP files, the passwordless database tools, metrics and backups do need an SSH server on the target (TERMALIN_TARGET) and the sign-in line added there — the image itself has none.

How do I update the container?

The container never updates itself. Pull a new tag and recreate it — docker compose pull && docker compose up -d, or a new image tag in the Deployment. The /data volume keeps the enrolment, so you don’t enrol again. The cabinet shows no “Update now” for a container.

What does it cost, and what plan do I need?

The Connector is a hosted feature, so it needs a Termalin account on Pro or the 14-day trial. The image is free to pull; there is no cap on how many Connectors you add.

Reach the whole network from one container.

A token, a docker run, no inbound port. On a NAS, a router, Kubernetes or ECS.

Pro (or 14-day trial) · Internal sites · Remote desktops