Getting Started with Identity Firewall SSH using Terraform
Deploy Twingate Privileged Access for SSH on DigitalOcean using Terraform with the HashiCorp TLS provider for certificate management.
Production Environments
The information in this article should be used as a guide only. If you are deploying this method into a production environment, we recommend that you also follow all security and configuration best practices.
Code samples in this guide may contain references to specific versions of software or container images that may not be the latest versions available. Please refer to the official documentation for that software or container image for the most up-to-date information.
What you will build
This guide walks you through deploying Twingate Privileged Access for SSH using Terraform and DigitalOcean. By the end, you will have:
- An SSH Certificate Authority and X.509 Certificate Authority generated by the HashiCorp TLS provider
- Both CAs registered with Twingate
- A Gateway registered with Twingate and bound to both CAs
- An SSH Resource accessible through the Gateway
- Three DigitalOcean Droplets: a Connector, a Gateway, and an SSH server
Here is how the components fit together:
[ SSH Client ] ── Twingate Client ── Connector ──> [ Gateway ] ──> [ SSH Server ] <====== Twingate Authentication & Authorization ======>The Twingate Client authenticates the user via your identity provider. The Connector routes traffic to the Gateway, which terminates the SSH session and re-authenticates to the target server using a short-lived certificate from a known CA. No SSH keys are distributed to users, and no bastion host is required.
The Terraform resources you will create:
tls_private_keyandtls_self_signed_cert— certificate generation via the HashiCorp TLS providertwingate_remote_network— a logical network groupingtwingate_connectorandtwingate_connector_tokens— the network agent that routes traffictwingate_ssh_certificate_authority— the SSH CA used to authenticate to target serverstwingate_x509_certificate_authority— the X.509 CA used for Client-to-Gateway TLStwingate_gateway— registers the Gateway with Twingate and binds it to the CAstwingate_ssh_resource— the SSH server you want to protecttwingate_gateway_config— generates the YAML configuration for the Gatewaydigitalocean_droplet— cloud VMs for the Gateway, Connector, and SSH serverdigitalocean_reserved_ip— a stable IP address for the Gateway
This guide uses DigitalOcean as a concrete example, but the pattern applies to any cloud provider that supports cloud-init (AWS, GCP, Azure, etc.). Replace the DigitalOcean resources with your provider’s equivalents.
Prerequisites
- Terraform installed (v1.11+)
- A Twingate account with an API token that has Read, Write & Provision permissions
- A Remote Network with at least one running Connector at version 1.82.0 or later
- A DigitalOcean account with an API token
- The Twingate Client at version 2025.310 or later (macOS, Windows, or Linux)
If you don’t have a Remote Network and Connector set up yet, see the Getting Started with Terraform and Twingate guide to deploy one alongside your cloud infrastructure.
Set up the Terraform provider
Create a new folder for your Terraform configuration:
mkdir twingate-ssh-democd twingate-ssh-demoCreate a file called main.tf with the provider configuration:
terraform { required_version = ">= 1.11"
required_providers { twingate = { source = "Twingate/twingate" version = "~> 4.1" } tls = { source = "hashicorp/tls" version = "~> 4.0" } digitalocean = { source = "digitalocean/digitalocean" version = "~> 2.0" } }}
provider "twingate" { api_token = var.tg_api_token network = var.tg_network}
provider "digitalocean" { token = var.do_token}Create a variables.tf file with the input variable declarations:
variable "tg_api_token" { description = "Twingate API token" type = string sensitive = true}
variable "tg_network" { description = "Twingate network name" type = string}
variable "resource_alias" { description = "Optional DNS alias for the SSH resource (added as a DNS SAN in the TLS cert)" type = string default = ""}
variable "do_token" { description = "DigitalOcean API token" type = string sensitive = true}
variable "do_region" { description = "DigitalOcean region for resources" type = string default = "nyc3"}
variable "do_droplet_size" { description = "DigitalOcean droplet size" type = string default = "s-1vcpu-1gb"}Create a terraform.tfvars file with your credentials:
tg_api_token = "YOUR_API_TOKEN"tg_network = "your-network-name"do_token = "YOUR_DIGITALOCEAN_TOKEN"Protect Your Credentials
Do not commit terraform.tfvars to source control. Add it to your .gitignore file to avoid accidentally exposing your API token.
To generate a Twingate API token, navigate to Settings > API in the Admin Console and click Generate Token. Select Read, Write & Provision permissions.
Run terraform init to download the providers:
terraform initGenerate Certificate Authorities
The HashiCorp tls provider generates all keys and certificates within Terraform. No manual CLI commands are needed.
SSH Certificate Authority
The SSH Certificate Authority is how the Gateway authenticates to your SSH servers. The Gateway holds the private key and signs short-lived certificates for each connection.
resource "tls_private_key" "ssh_ca" { algorithm = "ED25519"}X.509 Certificate Authority
The X.509 CA secures the TLS connection between the Twingate Client and the Gateway. This example uses a self-signed CA for simplicity. For production deployments, see Using Vault as an SSH Certificate Authority.
resource "tls_private_key" "x509_ca" { algorithm = "RSA" rsa_bits = 4096}
resource "tls_self_signed_cert" "x509_ca" { private_key_pem = tls_private_key.x509_ca.private_key_pem
subject { common_name = "Twingate Gateway CA" }
validity_period_hours = 8760 # 1 year is_ca_certificate = true
allowed_uses = [ "digital_signature", "key_encipherment", "cert_signing", "crl_signing", ]}Server TLS certificate
The Gateway needs a server certificate signed by the X.509 CA for TLS termination:
resource "tls_private_key" "server" { algorithm = "RSA" rsa_bits = 4096}
resource "tls_cert_request" "server" { private_key_pem = tls_private_key.server.private_key_pem
subject { common_name = "gateway" }
dns_names = var.resource_alias != "" ? [var.resource_alias] : [] ip_addresses = [digitalocean_droplet.ssh_server.ipv4_address_private]}
resource "tls_locally_signed_cert" "server" { cert_request_pem = tls_cert_request.server.cert_request_pem ca_private_key_pem = tls_private_key.x509_ca.private_key_pem ca_cert_pem = tls_self_signed_cert.x509_ca.cert_pem
validity_period_hours = 720 # 30 days
allowed_uses = [ "digital_signature", "key_encipherment", "server_auth", ]}The server certificate includes the SSH server’s private IP as a SAN (Subject Alternative Name) and optionally includes a DNS alias if resource_alias is set.
The tls provider stores private keys unencrypted in your Terraform state. This approach is suitable for development and testing. For production deployments, use HashiCorp Vault as your Certificate Authority so that private keys are managed by Vault and never appear in Terraform state.
Create the Twingate infrastructure
Remote Network and Connector
Start by creating a Remote Network and Connector. If you already have these, skip ahead and use data sources to reference them instead.
resource "twingate_remote_network" "main" { name = "SSH Demo Network"}
resource "twingate_connector" "main" { remote_network_id = twingate_remote_network.main.id name = "demo-connector"}
resource "twingate_connector_tokens" "main" { connector_id = twingate_connector.main.id}If you already have a Remote Network, use a data source to reference it:
data "twingate_remote_network" "existing" { name = "My Existing Network"}Then replace twingate_remote_network.main.id with data.twingate_remote_network.existing.id in subsequent resources.
Certificate Authorities
Register the SSH CA public key and X.509 CA certificate with Twingate:
resource "twingate_ssh_certificate_authority" "ssh" { name = "SSH Demo CA" public_key = tls_private_key.ssh_ca.public_key_openssh}
resource "twingate_x509_certificate_authority" "tls" { name = "SSH Demo X509 CA" certificate = tls_self_signed_cert.x509_ca.cert_pem}The tls provider outputs the SSH public key in OpenSSH format, so no file handling or trimming is needed.
Gateway
Register the Gateway with Twingate. This binds the Gateway to a Remote Network, a listen address, and both Certificate Authorities.
A DigitalOcean reserved IP provides a stable address for the Gateway:
locals { gateway_port = 8443}
resource "digitalocean_reserved_ip" "gateway" { region = var.do_region}
resource "twingate_gateway" "main" { remote_network_id = twingate_remote_network.main.id address = "${digitalocean_reserved_ip.gateway.ip_address}:${local.gateway_port}" x509_ca_id = twingate_x509_certificate_authority.tls.id ssh_ca_id = twingate_ssh_certificate_authority.ssh.id}Key fields:
address— the public IP and port where the Gateway will listen for incoming connections. Using a reserved IP ensures the address remains stable across droplet replacements.x509_ca_id— the X.509 CA used for TLS between the Twingate Client and the Gatewayssh_ca_id— the SSH CA used to sign short-lived certificates for SSH authentication
SSH Resource
Define the SSH server you want to protect. The address is the private IP or hostname reachable from the Gateway, and the alias is the DNS name users will use to connect.
data "twingate_groups" "everyone" { name = "Everyone"}
resource "twingate_ssh_resource" "ssh_server" { name = "Demo SSH Server" address = digitalocean_droplet.ssh_server.ipv4_address_private remote_network_id = twingate_remote_network.main.id gateway_id = twingate_gateway.main.id alias = var.resource_alias != "" ? var.resource_alias : null
access_group { group_id = data.twingate_groups.everyone.groups[0].id }}Key fields:
address— the private IP of the SSH server, referenced dynamically from the droplet resourcealias— an optional DNS name users type to connect (e.g.,ssh demo-server.int)gateway_id— the Gateway that routes SSH traffic to this serveraccess_group— grants a Twingate Group access to this Resource. Add users to the Group via the Admin Console
Gateway configuration
The twingate_gateway_config resource generates a YAML configuration file that you deploy alongside the Gateway binary.
resource "twingate_gateway_config" "config" { port = local.gateway_port
tls = { certificate_file = "/etc/gateway/tls.crt" private_key_file = "/etc/gateway/tls.key" }
ssh = { gateway = { username = "gateway" } ca = { private_key_file = "/opt/gateway/ssh-ca.key" }
resources = [ twingate_ssh_resource.ssh_server, ] }}Key fields:
port— the port the Gateway listens on for incoming connectionstls.certificate_file— path to the server TLS certificate on the Gateway hosttls.private_key_file— path to the server TLS private key on the Gateway hostssh.gateway.username— the OS-level user account the Gateway authenticates as on target serversssh.ca.private_key_file— path to the SSH CA private key on the Gateway hostssh.resources— the SSH Resources this Gateway serves
The generated YAML configuration is available in the twingate_gateway_config.config.content attribute. In this guide, it is injected into the Gateway VM via templatefile() in user_data rather than written to local disk.
Configure the SSH server
Each target SSH server needs two things: a dedicated user account for the Gateway to authenticate as, and trust in the SSH CA.
Create a startup script at scripts/ssh-server-startup.sh that automates this setup:
#!/bin/bashset -e
useradd -m -s /bin/bash gateway
cat > /etc/ssh/twingate-ca.pub <<'PUBKEY'${ssh_ca_public_key}PUBKEY
echo "TrustedUserCAKeys /etc/ssh/twingate-ca.pub" >> /etc/ssh/sshd_config
systemctl restart sshdThe ${ssh_ca_public_key} placeholder is filled by templatefile() when the SSH server droplet is created. After this change, the SSH server accepts certificates signed by your CA alongside any existing authentication methods.
For existing servers that are not provisioned by Terraform, run these commands manually:
sudo useradd -m -s /bin/bash gatewayecho "SSH_CA_PUBLIC_KEY" | sudo tee /etc/ssh/twingate-ca.pubecho "TrustedUserCAKeys /etc/ssh/twingate-ca.pub" | sudo tee -a /etc/ssh/sshd_configsudo systemctl restart sshdReplace SSH_CA_PUBLIC_KEY with the value of tls_private_key.ssh_ca.public_key_openssh from your Terraform state (terraform output or terraform state show).
Twingate Privileged Access for SSH can coexist with existing SSH authentication methods. Existing authorized_keys entries are unaffected, so you can migrate at your own pace.
Full Terraform configuration
Here is the complete configuration split across multiple files, matching the layout of the working example.
main.tf
terraform { required_version = ">= 1.11"
required_providers { twingate = { source = "Twingate/twingate" version = "~> 4.1" } tls = { source = "hashicorp/tls" version = "~> 4.0" } digitalocean = { source = "digitalocean/digitalocean" version = "~> 2.0" } }}
provider "twingate" { api_token = var.tg_api_token network = var.tg_network}
provider "digitalocean" { token = var.do_token}variables.tf
variable "tg_api_token" { description = "Twingate API token" type = string sensitive = true}
variable "tg_network" { description = "Twingate network name" type = string}
variable "resource_alias" { description = "Optional DNS alias for the SSH resource (added as a DNS SAN in the TLS cert)" type = string default = ""}
variable "do_token" { description = "DigitalOcean API token" type = string sensitive = true}
variable "do_region" { description = "DigitalOcean region for resources" type = string default = "nyc3"}
variable "do_droplet_size" { description = "DigitalOcean droplet size" type = string default = "s-1vcpu-1gb"}network.tf
data "digitalocean_vpc" "main" { region = var.do_region}This references the default VPC in your chosen DigitalOcean region.
twingate.tf
# --- TLS Certificate Resources ---
resource "tls_private_key" "x509_ca" { algorithm = "RSA" rsa_bits = 4096}
resource "tls_self_signed_cert" "x509_ca" { private_key_pem = tls_private_key.x509_ca.private_key_pem
subject { common_name = "Twingate Gateway CA" }
validity_period_hours = 8760 # 1 year is_ca_certificate = true
allowed_uses = [ "digital_signature", "key_encipherment", "cert_signing", "crl_signing", ]}
resource "tls_private_key" "server" { algorithm = "RSA" rsa_bits = 4096}
resource "tls_cert_request" "server" { private_key_pem = tls_private_key.server.private_key_pem
subject { common_name = "gateway" }
dns_names = var.resource_alias != "" ? [var.resource_alias] : [] ip_addresses = [digitalocean_droplet.ssh_server.ipv4_address_private]}
resource "tls_locally_signed_cert" "server" { cert_request_pem = tls_cert_request.server.cert_request_pem ca_private_key_pem = tls_private_key.x509_ca.private_key_pem ca_cert_pem = tls_self_signed_cert.x509_ca.cert_pem
validity_period_hours = 720 # 30 days
allowed_uses = [ "digital_signature", "key_encipherment", "server_auth", ]}
resource "tls_private_key" "ssh_ca" { algorithm = "ED25519"}
# --- Twingate Resources ---
resource "twingate_remote_network" "main" { name = "SSH Demo Network"}
resource "twingate_ssh_certificate_authority" "ssh" { name = "SSH Demo CA" public_key = tls_private_key.ssh_ca.public_key_openssh}
resource "twingate_x509_certificate_authority" "tls" { name = "SSH Demo X509 CA" certificate = tls_self_signed_cert.x509_ca.cert_pem}
resource "twingate_gateway" "main" { remote_network_id = twingate_remote_network.main.id address = "${digitalocean_reserved_ip.gateway.ip_address}:${local.gateway_port}" x509_ca_id = twingate_x509_certificate_authority.tls.id ssh_ca_id = twingate_ssh_certificate_authority.ssh.id}
resource "twingate_connector" "main" { remote_network_id = twingate_remote_network.main.id name = "demo-connector"}
resource "twingate_connector_tokens" "main" { connector_id = twingate_connector.main.id}
data "twingate_groups" "everyone" { name = "Everyone"}
resource "twingate_ssh_resource" "ssh_server" { name = "Demo SSH Server" address = digitalocean_droplet.ssh_server.ipv4_address_private remote_network_id = twingate_remote_network.main.id gateway_id = twingate_gateway.main.id alias = var.resource_alias != "" ? var.resource_alias : null
access_group { group_id = data.twingate_groups.everyone.groups[0].id }}gateway.tf
locals { gateway_port = 8443}
resource "digitalocean_reserved_ip" "gateway" { region = var.do_region}
resource "digitalocean_reserved_ip_assignment" "gateway" { ip_address = digitalocean_reserved_ip.gateway.ip_address droplet_id = digitalocean_droplet.gateway.id}
resource "twingate_gateway_config" "config" { port = local.gateway_port
tls = { certificate_file = "/etc/gateway/tls.crt" private_key_file = "/etc/gateway/tls.key" }
ssh = { gateway = { username = "gateway" } ca = { private_key_file = "/opt/gateway/ssh-ca.key" }
resources = [ twingate_ssh_resource.ssh_server, ] }}
resource "digitalocean_droplet" "gateway" { name = "demo-gateway" region = var.do_region size = var.do_droplet_size image = "debian-12-x64"
vpc_uuid = data.digitalocean_vpc.main.id
user_data = templatefile("${path.module}/scripts/gateway-startup.sh", { tls_cert = tls_locally_signed_cert.server.cert_pem tls_key = tls_private_key.server.private_key_pem ssh_ca_key = tls_private_key.ssh_ca.private_key_openssh gateway_config = twingate_gateway_config.config.content })
lifecycle { replace_triggered_by = [ twingate_gateway_config.config, ] }}The replace_triggered_by lifecycle rule recreates the Gateway droplet whenever the gateway configuration changes, keeping the deployed configuration in sync with Terraform.
connector.tf
resource "digitalocean_droplet" "connector" { name = "demo-connector" region = var.do_region size = var.do_droplet_size image = "debian-12-x64"
vpc_uuid = data.digitalocean_vpc.main.id
user_data = templatefile("${path.module}/scripts/connector-startup.sh", { access_token = twingate_connector_tokens.main.access_token refresh_token = twingate_connector_tokens.main.refresh_token twingate_url = "https://${var.tg_network}.twingate.com" })}ssh-server.tf
resource "digitalocean_droplet" "ssh_server" { name = "demo-ssh-server" region = var.do_region size = var.do_droplet_size image = "debian-12-x64"
vpc_uuid = data.digitalocean_vpc.main.id
user_data = templatefile("${path.module}/scripts/ssh-server-startup.sh", { ssh_ca_public_key = tls_private_key.ssh_ca.public_key_openssh })}terraform.tfvars
tg_api_token = "YOUR_API_TOKEN"tg_network = "your-network-name"do_token = "YOUR_DIGITALOCEAN_TOKEN"Startup scripts
Create a scripts/ directory with three files:
scripts/gateway-startup.sh
#!/bin/bashset -e
GATEWAY_DIR="/opt/gateway"# Check https://github.com/Twingate/gateway/releases for the latest versionBINARY_URL="https://github.com/Twingate/gateway/releases/download/v0.13.0/gateway_Linux_x86_64.tar.gz"
mkdir -p "$GATEWAY_DIR"mkdir -p /etc/gateway
cat > /etc/gateway/tls.crt <<'CERT'${tls_cert}CERT
cat > /etc/gateway/tls.key <<'KEY'${tls_key}KEY
chmod 600 /etc/gateway/tls.key
cat > "$GATEWAY_DIR/ssh-ca.key" <<'SSHKEY'${ssh_ca_key}SSHKEY
chmod 600 "$GATEWAY_DIR/ssh-ca.key"
cat > "$GATEWAY_DIR/config.yaml" <<'CONFIG'${gateway_config}CONFIG
curl -sfL "$BINARY_URL" | tar xz -C "$GATEWAY_DIR"
cat > /etc/systemd/system/gateway.service <<EOF[Unit]Description=Twingate Access GatewayAfter=network.target
[Service]ExecStart=$GATEWAY_DIR/gateway start --config $GATEWAY_DIR/config.yamlRestart=alwaysRestartSec=5
[Install]WantedBy=multi-user.targetEOF
systemctl daemon-reloadsystemctl enable --now gatewayscripts/connector-startup.sh
#!/bin/bashset -e
curl "https://binaries.twingate.com/connector/setup.sh" | \ sudo TWINGATE_ACCESS_TOKEN="${access_token}" \ TWINGATE_REFRESH_TOKEN="${refresh_token}" \ TWINGATE_URL="${twingate_url}" \ bashscripts/ssh-server-startup.sh
#!/bin/bashset -e
useradd -m -s /bin/bash gateway
cat > /etc/ssh/twingate-ca.pub <<'PUBKEY'${ssh_ca_public_key}PUBKEY
echo "TrustedUserCAKeys /etc/ssh/twingate-ca.pub" >> /etc/ssh/sshd_config
systemctl restart sshdFor Kubernetes deployments, see the Gateway deployment repository for Helm charts and reference architectures.
Deploy
Run terraform plan to preview the resources that will be created:
terraform planReview the plan output to confirm the resources match your expectations, then apply:
terraform applyType yes when prompted to confirm.
After Terraform completes, three DigitalOcean Droplets boot up. Cloud-init runs the startup scripts on each droplet to configure keys, certificates, and services. The Gateway starts as a systemd service listening on port 8443.
Verify the connection
- The Terraform configuration grants the Everyone Group access to the Resource. In the Admin Console, navigate to Teams > Groups and confirm your user is in the Everyone Group.
- Open the Twingate Client on your machine.
- Under More, select Auto-sync SSH Server Configuration. This updates
~/.ssh/known_hostswith the CA’s public key so you won’t see TOFU warnings. - Connect to the SSH Resource using the alias you configured:
ssh demo-server.intThe Twingate Client authenticates you in the background via your identity provider. The Gateway issues a short-lived certificate and connects you to the target server. No SSH keys needed on your device.
- Verify session recording by checking the Gateway logs:
journalctl -u gatewayYou should see session activity in asciicast v2 format. These logs can be forwarded to your SIEM or object storage for compliance.
Clean up
When you are done testing, remove all resources:
terraform destroyterraform destroy will remove all Twingate Resources, Certificate Authorities, and DigitalOcean Droplets created by this configuration. Users will lose SSH access to the protected servers immediately.
Next steps
- Using Vault as an SSH Certificate Authority — the recommended approach for production deployments
- Privileged Access for SSH overview — full feature documentation including supported protocols and security considerations
- Remote development with Twingate SSH — set up VS Code, JetBrains, and Cursor for remote development
- Managing contractor and vendor SSH access — grant time-limited access with automatic expiration
- SSH session recording for compliance — configure session capture and SIEM forwarding
- Automating infrastructure with Ansible — run playbooks over Twingate SSH with no key management
Join us in the community subreddit to share your setup experience or ask questions.
Last updated 5 days ago