Using HashiCorp Vault as an SSH Certificate Authority

Replace self-signed CAs with Vault-managed certificate authorities for production Gateway deployments.

This guide builds on the Getting Started with IDFW SSH using Terraform guide. That guide uses the HashiCorp tls provider to generate self-signed CAs, which is fine for development and testing but stores private keys unencrypted in Terraform state. For production deployments, we recommend using HashiCorp Vault as your Certificate Authority instead.

With Vault, CA private keys are generated and stored inside Vault. They never appear in Terraform state, on disk, or in startup scripts. The Gateway authenticates to Vault at runtime to request signed certificates, and Vault provides audit logging for every signing operation.

A complete, runnable Terraform example is available in the examples/gce-gateway-ssh-vault directory of the Terraform provider repository. This guide highlights the key differences from the self-signed approach.

What changes with Vault

ComponentSelf-Signed (base guide)Vault
SSH CA key pairtls_private_key resource, private key stored in Terraform statevault_ssh_secret_backend_ca with generate_signing_key, private key stays inside Vault
X.509 CAtls_self_signed_cert, private key stored in Terraform statevault_pki_secret_backend_root_cert, private key stays inside Vault
Gateway configssh.ca.private_key_file pointing to a key file on the Gateway VMssh.ca.vault block with Vault address, mount path, signing role, and auth config
SSH server trustStatic CA public key written via cloud-initVM authenticates to Vault at startup to obtain a signed host certificate

Prerequisites

  • Everything listed in the Terraform guide prerequisites, except that this example uses GCP instead of DigitalOcean
  • A HashiCorp Vault instance with the SSH secrets engine enabled (self-hosted, HCP Vault, or the included sample deployment)
  • A GCP project with the Compute Engine API enabled
  • The gcloud CLI installed and authenticated

Configure the Vault SSH secrets engine

The SSH secrets engine is the core of this setup. It generates the SSH CA key pair and signs certificates on demand.

Mount and CA key

resource "vault_mount" "ssh" {
path = "ssh"
type = "ssh"
}
resource "vault_ssh_secret_backend_ca" "ssh" {
backend = vault_mount.ssh.path
generate_signing_key = true
key_type = "ssh-ed25519"
}

Setting generate_signing_key = true tells Vault to create the ED25519 key pair internally. The public key is available via vault_ssh_secret_backend_ca.ssh.public_key for registering with Twingate and configuring SSH servers.

Signing roles

Signing roles define what types of certificates Vault can issue. The Gateway needs a role that allows both user and host certificates:

resource "vault_ssh_secret_backend_role" "gateway" {
name = "gateway"
backend = vault_mount.ssh.path
key_type = "ca"
ttl = "720h" # 30 days
max_ttl = "8760h" # 365 days
allow_empty_principals = true
allow_host_certificates = true
allow_user_certificates = true
allowed_domains = "*"
allowed_users = "gateway"
allowed_extensions = "permit-X11-forwarding,permit-agent-forwarding,permit-port-forwarding,permit-pty,permit-user-rc"
}

A Vault policy scopes the Gateway’s access to only its signing endpoint and the CA’s public key:

resource "vault_policy" "gateway" {
name = "gateway-signing"
policy = <<-EOT
path "${vault_mount.ssh.path}/sign/${vault_ssh_secret_backend_role.gateway.name}" {
capabilities = ["create", "update"]
}
path "${vault_mount.ssh.path}/config/ca" {
capabilities = ["read"]
}
EOT
}

Create a separate role for SSH servers that is restricted to host certificates only. See the full example for the complete configuration.

Configure the Vault PKI engine (optional)

The PKI secrets engine can issue the X.509 certificates used for TLS between the Twingate Client and the Gateway. This is optional — any X.509 CA source works (including the tls provider from the base guide, or your organization’s existing PKI).

resource "vault_mount" "pki" {
path = "pki"
type = "pki"
description = "PKI backend for X.509 certificates"
default_lease_ttl_seconds = 3600
max_lease_ttl_seconds = 31536000 # 1 year
}
resource "vault_pki_secret_backend_root_cert" "root" {
backend = vault_mount.pki.path
type = "internal"
common_name = "Demo Root CA"
ttl = "8760h" # 1 year
}

A server certificate is issued from this CA at apply time and injected into the Gateway VM via templatefile(). See the full example for the vault_pki_secret_backend_cert resource configuration.

Set up Vault authentication

VMs authenticate to Vault using GCP identity tokens. Each VM role is scoped to specific policies:

resource "vault_auth_backend" "gcp" {
type = "gcp"
}
resource "vault_gcp_auth_backend_role" "gateway" {
backend = vault_auth_backend.gcp.path
role = "gateway-role"
type = "gce"
token_policies = [vault_policy.gateway.name]
token_ttl = 86400 # 24 hours
token_max_ttl = 86400
bound_projects = [var.project_id]
bound_zones = [var.zone]
bound_service_accounts = [google_service_account.gateway.email]
}

The bound_service_accounts field ensures that only the Gateway’s specific GCP service account can authenticate with this role. The SSH server has its own role scoped to a different policy that only permits host certificate signing.

Register CAs with Twingate

The Twingate resources are the same as in the base guide, but the CA values come from Vault instead of the tls provider:

resource "twingate_ssh_certificate_authority" "vault" {
name = "Vault SSH CA"
public_key = vault_ssh_secret_backend_ca.ssh.public_key
}
resource "twingate_x509_certificate_authority" "vault" {
name = "Vault X509 CA"
certificate = vault_pki_secret_backend_root_cert.root.certificate
}
resource "twingate_gateway" "vault" {
remote_network_id = twingate_remote_network.main.id
address = "${google_compute_address.gateway.address}:${local.gateway_port}"
x509_ca_id = twingate_x509_certificate_authority.vault.id
ssh_ca_id = twingate_ssh_certificate_authority.vault.id
}

The remaining Twingate resources (Remote Network, Connector, SSH Resource) are configured the same way as in the base guide.

Configure the Gateway for Vault

This is the key difference from the self-signed approach. Instead of pointing to a local private key file, the Gateway configuration includes a vault block that tells the Gateway how to authenticate to Vault and request signed certificates at runtime.

In the base guide, the Gateway config uses:

ssh = {
ca = { private_key_file = "/opt/gateway/ssh-ca.key" }
}

With Vault, it becomes:

resource "twingate_gateway_config" "config" {
port = local.gateway_port
tls = {
certificate_file = "/opt/gateway/tls.crt"
private_key_file = "/opt/gateway/tls.key"
}
ssh = {
gateway = {
username = "gateway"
}
ca = {
vault = {
address = "https://${data.terraform_remote_state.vault.outputs.vault_internal_ip}:8200"
ca_bundle_file = "/etc/ssl/vault-ca.crt"
mount = vault_mount.ssh.path
role = vault_ssh_secret_backend_role.gateway.name
auth = {
gcp = {
type = "gce"
mount = vault_auth_backend.gcp.path
role = vault_gcp_auth_backend_role.gateway.role
}
}
}
}
resources = [twingate_ssh_resource.ssh_server]
}
}

Key fields in the ssh.ca.vault block:

  • address — the Vault server URL reachable from the Gateway VM
  • ca_bundle_file — path on the Gateway VM to the CA certificate used to verify Vault’s TLS certificate
  • mount — the SSH secrets engine mount path (default: ssh)
  • role — the signing role that defines certificate parameters (TTL, allowed users, extensions)
  • auth.gcp — configures GCP auth with the type (gce or iam), auth backend mount path, and role name

The SSH CA private key no longer needs to be written to the Gateway VM. The Gateway authenticates to Vault at runtime using its GCE instance identity and requests signed certificates on demand.

Configure the SSH server

In the base guide, the SSH server startup script writes a static CA public key and configures TrustedUserCAKeys. With Vault, the SSH server also authenticates to Vault to obtain a signed host certificate, which eliminates TOFU warnings for the Gateway’s connection to the SSH server.

#!/bin/bash
set -euo pipefail
apt-get update -qq && apt-get install -y -qq jq
# Create the gateway user
useradd -m -s /bin/bash gateway
# Authenticate to Vault via GCP auth
JWT=$(curl -sf -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=vault/${vault_gcp_role}&format=full")
LOGIN_RESPONSE=$(curl -s --fail-with-body \
--cacert /etc/ssl/vault-ca.crt \
-X POST \
--data "{\"role\": \"${vault_gcp_role}\", \"jwt\": \"$JWT\"}" \
"${vault_addr}/v1/auth/${vault_gcp_mount}/login")
VAULT_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.auth.client_token')
# Sign the host key with the Vault SSH CA
HOST_PUB_KEY=$(cat /etc/ssh/ssh_host_ed25519_key.pub)
RESPONSE=$(curl -s --fail-with-body \
--cacert /etc/ssl/vault-ca.crt \
-H "X-Vault-Token: $VAULT_TOKEN" \
-X POST \
--data "{\"public_key\": \"$HOST_PUB_KEY\", \"cert_type\": \"host\", \"ttl\": \"8760h\"}" \
"${vault_addr}/v1/${vault_mount}/sign/${vault_role}")
echo "$RESPONSE" | jq -r '.data.signed_key' > /etc/ssh/ssh_host_ed25519_key-cert.pub
# Configure sshd to trust CA and present host certificate
echo "TrustedUserCAKeys /etc/ssh/vault-ssh-ca.pub" >> /etc/ssh/sshd_config
echo "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub" >> /etc/ssh/sshd_config
systemctl restart sshd

The ${vault_*} placeholders are filled by templatefile() when the SSH server instance is created. The script authenticates to Vault using the VM’s GCP identity token, then requests a signed host certificate for the server’s ED25519 key.

Deploy

The Vault setup is a two-step deployment because Vault must be running before the root module can configure its secrets engines and issue certificates.

Step 1: Deploy Vault infrastructure

The vault/ subdirectory provisions the VPC, subnet, Cloud NAT, and Vault VM:

cd vault/
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your GCP project ID
terraform init
terraform apply

After Vault starts (approximately 60 seconds), retrieve the root token:

gcloud compute ssh demo-vault-server --zone us-central1-a --tunnel-through-iap -- \
"sudo cat /opt/vault/init-output.json"

Step 2: Deploy the root module

Start an IAP tunnel to Vault in a separate terminal:

gcloud compute start-iap-tunnel demo-vault-server 8200 \
--local-host-port=localhost:8200 --zone=us-central1-a

Then deploy the Gateway, Connector, and SSH server:

cd ..
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your credentials and the vault_token from step 1
terraform init
terraform apply

After Terraform completes, verify the connection using the same steps as in the base guide.

Clean up

Remove all resources in reverse order:

terraform destroy
cd vault && terraform destroy

Next steps

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

Last updated 1 day ago