-
Notifications
You must be signed in to change notification settings - Fork 3
SSH Installation
For the easiest setup, follow the SSH Quick Start Guide, which uses Terraform to provision the Gateway and an SSH server end to end. This guide is a manual alternative aimed at simple homelab setups, and at readers who want to see how the pieces fit together. It runs the Gateway as a standalone Docker container with Manual CA mode and configures an upstream SSH server to trust it. See SSH Overview for the underlying concepts.
Before you begin, make sure you have:
- A Twingate account with administrator privileges
- A Linux host with Docker installed, reachable from the Connector on the Gateway's listen port (default
8443) - One or more SSH servers, each:
- Reachable from the Gateway host on its SSH port
- With a Linux user account that the Gateway will log in as (e.g.
admin) - Where you can edit
sshd_configand reload sshd (sudo access)
-
curl,jq,ssh-keygen, andopensslon the workstation running this guide
- Log in to your Twingate Admin Console at
https://<network-name>.twingate.com - Create a Remote Network that represents your homelab or environment:
- Navigate to Network tab > Remote Networks and click the "+ Remote Network" button.
- Take note of the Remote Network ID from the URL:
https://<network-name>.twingate.com/networks/<remote-network-id>.
- Create an API key with
Read & Writepermissions:- Settings > API (or navigate to
https://<network-name>.twingate.com/settings/api) - Save the API key securely. You will not be able to see it again.
- Settings > API (or navigate to
The remaining steps drive the Twingate API from the shell. Export these once and reuse them throughout the guide:
export TWINGATE_TENANT="<network-name>"
export TWINGATE_API_TOKEN="<api-token>"
export REMOTE_NETWORK_ID="<remote-network-id>"- Open the Remote Network you just created and click "Deploy Connector".
- Choose the Docker option. The Admin Console generates a
docker runcommand that includes the Connector tokens. - Run that command on any always-on host. Running it on the same host as the Gateway is fine.
- Confirm the Connector is online in the Admin Console.
Tip
Skip this step if you already have a Connector online for this Remote Network.
Important
The Connector must be running version 1.82.0 or later. The Admin Console's "Deploy Connector" command pulls the latest image, so a fresh install is fine. If you're reusing an existing Connector, check its version on the Admin Console's Connectors page and update it if needed.
ssh-keygen -t ed25519 -f ssh_ca -N "" -C "twingate-gateway-ca"This produces two files:
-
ssh_ca, the private key used by the Gateway to sign user certificates. -
ssh_ca.pub, the public key registered with Twingate in Step 5 and copied to every upstream SSH server in Step 9.
The certificate must include each SSH server's address and alias as Subject Alternative Names so the Twingate Client accepts the Gateway's TLS cert when connecting through any of them.
openssl ecparam -genkey -name prime256v1 -noout -out tls.key
openssl req -new -x509 -key tls.key -out tls.crt \
-days 3650 -subj "/CN=Twingate Gateway TLS CA" \
-addext "subjectAltName=DNS:home.int,IP:192.168.1.10"For multiple upstreams, append more DNS: and IP: entries, e.g. -addext "subjectAltName=DNS:home.int,IP:192.168.1.10,DNS:nas.int,IP:192.168.1.11".
This produces tls.crt and tls.key. The Gateway uses the same self-signed certificate as both its TLS CA (registered with Twingate so the Client trusts the Gateway) and as the cert it serves on the outer TLS connection.
Three GraphQL mutations against https://${TWINGATE_TENANT}.twingate.com/api/graphql/. Run them in order. Each saves an ID into a shell variable that the next step uses.
SSH_CA_ID=$(curl -s -X POST \
-H "X-API-KEY: ${TWINGATE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg pk "$(cat ssh_ca.pub)" '{
query: "mutation($name: String!, $publicKey: String!) {
sshCertificateAuthorityCreate(name: $name, publicKey: $publicKey) {
ok
error
entity { id }
}
}",
variables: { name: "Homelab SSH CA", publicKey: $pk }
}')" \
"https://${TWINGATE_TENANT}.twingate.com/api/graphql/" \
| jq -r '.data.sshCertificateAuthorityCreate.entity.id')
echo "SSH_CA_ID=$SSH_CA_ID"X509_CA_ID=$(curl -s -X POST \
-H "X-API-KEY: ${TWINGATE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg cert "$(cat tls.crt)" '{
query: "mutation($name: String!, $certificate: String!) {
x509CertificateAuthorityCreate(name: $name, certificate: $certificate) {
ok
error
entity { id }
}
}",
variables: { name: "Homelab X509 CA", certificate: $cert }
}')" \
"https://${TWINGATE_TENANT}.twingate.com/api/graphql/" \
| jq -r '.data.x509CertificateAuthorityCreate.entity.id')
echo "X509_CA_ID=$X509_CA_ID"GATEWAY_ADDRESS is the IP:PORT where the Gateway will listen and where the Connector will reach it.
GATEWAY_ADDRESS="192.168.1.5:8443"
GATEWAY_ID=$(curl -s -X POST \
-H "X-API-KEY: ${TWINGATE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg address "$GATEWAY_ADDRESS" \
--arg remoteNetworkId "$REMOTE_NETWORK_ID" \
--arg x509CAId "$X509_CA_ID" \
--arg sshCAId "$SSH_CA_ID" '{
query: "mutation($address: String!, $remoteNetworkId: ID!, $x509CAId: ID!, $sshCAId: ID) {
gatewayCreate(
address: $address,
remoteNetworkId: $remoteNetworkId,
x509CAId: $x509CAId,
sshCAId: $sshCAId
) {
ok
error
entity { id }
}
}",
variables: { address: $address, remoteNetworkId: $remoteNetworkId, x509CAId: $x509CAId, sshCAId: $sshCAId }
}')" \
"https://${TWINGATE_TENANT}.twingate.com/api/graphql/" \
| jq -r '.data.gatewayCreate.entity.id')
echo "GATEWAY_ID=$GATEWAY_ID"Run one mutation per SSH server. address is the upstream's IP or hostname without a port. alias is the friendly DNS name users will type to connect (e.g. ssh admin@home.int). Both must be present in the TLS cert SANs from Step 4.
RESOURCE_ID=$(curl -s -X POST \
-H "X-API-KEY: ${TWINGATE_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg name "home-server" \
--arg address "192.168.1.10" \
--arg alias "home.int" \
--arg remoteNetworkId "$REMOTE_NETWORK_ID" \
--arg gatewayId "$GATEWAY_ID" '{
query: "mutation($name: String!, $address: String!, $alias: String, $remoteNetworkId: ID!, $gatewayId: ID!) {
sshResourceCreate(
name: $name,
address: $address,
alias: $alias,
remoteNetworkId: $remoteNetworkId,
gatewayId: $gatewayId
) {
ok
error
entity { id }
}
}",
variables: { name: $name, address: $address, alias: $alias, remoteNetworkId: $remoteNetworkId, gatewayId: $gatewayId }
}')" \
"https://${TWINGATE_TENANT}.twingate.com/api/graphql/" \
| jq -r '.data.sshResourceCreate.entity.id')
echo "RESOURCE_ID=$RESOURCE_ID"After creating the resource, open the Admin Console and assign your user or group to it.
Create gateway-config.yaml. Replace <network-name> with your tenant name. Add one entry under ssh.upstreams for each SSH resource you created in Step 6.
twingate:
network: <network-name>
port: 8443
tls:
certificateFile: /etc/gateway/tls.crt
privateKeyFile: /etc/gateway/tls.key
ssh:
gateway:
username: admin
ca:
manual:
privateKeyFile: /etc/gateway/ssh_ca
upstreams:
- name: home-server
address: 192.168.1.10:22ssh.gateway.username is the Linux account the Gateway logs in as on every upstream. The upstream address includes the SSH port.
Start the Gateway with Docker:
docker run -d --name twingate-gateway \
-p 8443:8443 \
-v $(pwd)/gateway-config.yaml:/etc/gateway/config.yaml:ro \
-v $(pwd)/tls.crt:/etc/gateway/tls.crt:ro \
-v $(pwd)/tls.key:/etc/gateway/tls.key:ro \
-v $(pwd)/ssh_ca:/etc/gateway/ssh_ca:ro \
twingate/gateway:latest \
start --config /etc/gateway/config.yamlCheck the logs to confirm the Gateway started:
docker logs twingate-gatewayTip
If you prefer not to use Docker, download the latest binary from GitHub Releases and run ./gateway start --config ./gateway-config.yaml. A small systemd unit can wrap this for unattended restart.
On every server listed under ssh.upstreams, copy the Gateway's SSH CA public key and tell sshd to trust it:
sudo install -m 0644 ssh_ca.pub /etc/ssh/twingate_ca.pub
echo "TrustedUserCAKeys /etc/ssh/twingate_ca.pub" | \
sudo tee /etc/ssh/sshd_config.d/twingate.conf
sudo sshd -t && sudo systemctl reload sshImportant
The Linux user account matching ssh.gateway.username (admin in the example above) must already exist on the upstream server.
- In your Twingate Client, you should see the "home-server" resource. Hovering the resource shows a "Sync SSH Configuration" button.
- Click the button. The Client adds
@cert-authorityentries for the Gateway to your~/.ssh/known_hostsfile. - Connect using the resource alias (or address) and the configured username:
ssh admin@home.intNo key or password is needed. The Gateway issues a short-lived SSH certificate signed by the SSH CA, and the upstream sshd trusts it via TrustedUserCAKeys.
Congratulations! You have successfully set up SSH access via the Gateway.
By default, the Gateway uses Trust-On-First-Use (TOFU) to record each upstream SSH server's host key on the first connection. To eliminate TOFU, sign a host certificate with the same CA so the Gateway can verify the upstream's identity cryptographically.
On each upstream:
sudo ssh-keygen -s ssh_ca -I <hostname> -h \
-n <hostname>,<address> -V +52w \
/etc/ssh/ssh_host_ed25519_key.pubAdd the host key and certificate to /etc/ssh/sshd_config.d/twingate.conf:
HostKey /etc/ssh/ssh_host_ed25519_key
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
TrustedUserCAKeys /etc/ssh/twingate_ca.pub
Reload sshd and the Gateway will verify the upstream host certificate against the CA on every connection.
Copyright © 2025 Twingate.
Kubernetes
SSH
Web App
Operations
Development
Migration