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.

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_key and tls_self_signed_cert — certificate generation via the HashiCorp TLS provider
  • twingate_remote_network — a logical network grouping
  • twingate_connector and twingate_connector_tokens — the network agent that routes traffic
  • twingate_ssh_certificate_authority — the SSH CA used to authenticate to target servers
  • twingate_x509_certificate_authority — the X.509 CA used for Client-to-Gateway TLS
  • twingate_gateway — registers the Gateway with Twingate and binds it to the CAs
  • twingate_ssh_resource — the SSH server you want to protect
  • twingate_gateway_config — generates the YAML configuration for the Gateway
  • digitalocean_droplet — cloud VMs for the Gateway, Connector, and SSH server
  • digitalocean_reserved_ip — a stable IP address for the Gateway

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)

Set up the Terraform provider

Create a new folder for your Terraform configuration:

mkdir twingate-ssh-demo
cd twingate-ssh-demo

Create 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"

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 init

Generate 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.

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
}

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 Gateway
  • ssh_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 resource
  • alias — an optional DNS name users type to connect (e.g., ssh demo-server.int)
  • gateway_id — the Gateway that routes SSH traffic to this server
  • access_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 connections
  • tls.certificate_file — path to the server TLS certificate on the Gateway host
  • tls.private_key_file — path to the server TLS private key on the Gateway host
  • ssh.gateway.username — the OS-level user account the Gateway authenticates as on target servers
  • ssh.ca.private_key_file — path to the SSH CA private key on the Gateway host
  • ssh.resources — the SSH Resources this Gateway serves

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/bash
set -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 sshd

The ${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.

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/bash
set -e
GATEWAY_DIR="/opt/gateway"
# Check https://github.com/Twingate/gateway/releases for the latest version
BINARY_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 Gateway
After=network.target
[Service]
ExecStart=$GATEWAY_DIR/gateway start --config $GATEWAY_DIR/config.yaml
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now gateway

scripts/connector-startup.sh

#!/bin/bash
set -e
curl "https://binaries.twingate.com/connector/setup.sh" | \
sudo TWINGATE_ACCESS_TOKEN="${access_token}" \
TWINGATE_REFRESH_TOKEN="${refresh_token}" \
TWINGATE_URL="${twingate_url}" \
bash

scripts/ssh-server-startup.sh

#!/bin/bash
set -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 sshd

Deploy

Run terraform plan to preview the resources that will be created:

terraform plan

Review the plan output to confirm the resources match your expectations, then apply:

terraform apply

Type 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_hosts with 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.int

The 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 gateway

You 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 destroy

Next steps

Join us in the community subreddit to share your setup experience or ask questions.

Last updated 5 days ago