Ansible and Terraform Still Need SSH Keys. That's a Problem You Can Fix

•

Product Marketing Engineer

•

Icons for infrastructure-as-code, a terminal, and an unlocked padlock on a dark grid background.

TL;DR: A look at how infrastructure-as-code tools handle SSH authentication, why the common patterns create long-term credential debt, and how Twingate Privileged Access for SSH removes SSH keys from the loop entirely.

Infrastructure-as-code solved the problem of describing what should exist. It did not solve the problem of how the tool doing the describing gets in the door.

Ansible SSHes into hosts. Terraform's remote-exec provisioner SSHes into hosts. Packer SSHes into build machines. Every one of these needs a credential from somewhere, and somewhere is where things get messy.

If you have ever inherited an Ansible codebase and gone looking for how it authenticates, you know the answer is usually one of three things: a key file on the operator's laptop, a key file baked into a CI runner, or a shared key sitting in a secrets manager that gets copied around. Occasionally, if the team has invested the time, it's a Vault SSH secrets engine issuing short-lived certificates. That last one is the "right" answer, and it's also why so few teams do it.

This post walks through the common SSH authentication patterns for Ansible and Terraform, what each actually costs to operate, and how routing IaC traffic through a Zero Trust access layer removes the credential problem instead of managing it.

The SSH problem IaC never solved

Ansible and Terraform were designed around SSH because SSH was what existed. Ansible connects to managed nodes over SSH and runs Python. Terraform providers mostly talk to cloud APIs, but the moment you need to touch a machine directly (remote-exec, file, Packer builds, kubeadm bootstrapping) you're back to SSH.

Neither tool has an opinion about where the SSH credential comes from. That's not a bug. It's a deliberate scope choice. The IaC tool assumes the operator has already figured out authentication. The result is that credential management gets pushed to whoever is running the tool, which in practice means it gets pushed nowhere until someone gets audited.

The three patterns that emerge look like this:

Pattern

Where the key lives

Rotation

Blast radius

Laptop key

~/.ssh/id_ed25519 on the operator's machine

When the operator leaves, maybe

Every host the operator can reach

CI runner key

Injected as a secret at pipeline start

Whenever someone remembers

Every host the pipeline targets

Vault-signed cert

Issued on demand by Vault SSH engine

Automatic, minutes-long TTL

Bounded by cert TTL

The Vault approach is architecturally sound. It's also a distributed system you now have to run to avoid distributing secrets. For a mid-sized platform team, that's a reasonable trade. For most teams, it's an aspiration on the roadmap that keeps slipping.

What Ansible's SSH authentication actually looks like

Ansible's connection to managed hosts is defined at the inventory or play level. In the default setup, you point it at a private key:

Run a playbook against a group of hosts using a local key:

Or, more commonly, the key path lives in ansible.cfg or a variable:

An inventory file specifying the SSH key per host group:

[web]

That key needs to exist wherever Ansible runs. On a laptop, that's an .ssh directory. In CI, it's a secret injected at runtime, usually written to a temporary file so ssh-agent or Ansible can find it. In an Ansible Tower or AWX setup, credentials are stored in the controller's database.

Every one of these places is a copy of the credential. Every copy is a rotation problem. When someone leaves the team, or when a laptop gets lost, the rotation involves generating a new key, deploying the public half to every managed host, and updating every copy of the private half. Most teams do the first step and skip the second because tracking down every copy is a research project.

Ansible does support alternatives — Kerberos, SSH certificates, connection plugins that use cloud provider SSM sessions — but adoption is uneven and each option comes with its own operational baggage.

What Terraform's SSH authentication actually looks like

Terraform's SSH exposure is narrower but structurally identical. It shows up in provisioner "remote-exec", provisioner "file", and any provider that reaches into a host (Packer via the Terraform provider, Consul, kubeadm modules).

A typical Terraform provisioner block:

resource "aws_instance" "app" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.medium"

  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file("~/.ssh/terraform_deploy")
    host        = self.private_ip
  }

  provisioner "remote-exec" {
    inline = [
      "sudo systemctl restart app",
    ]

The file("~/.ssh/terraform_deploy") line is the problem. That key has to exist on the machine running terraform apply. In a Terraform Cloud or Atlantis setup, it means the runner has the key. In a self-hosted setup, it means the CI runner has the key. Either way, the credential is now distributed across every place plans and applies can happen.

There's also the network problem. self.private_ip implies the Terraform runner is on the same network as the target instance, or reaches it through a VPN, or you've exposed SSH to the public internet with an IP allowlist. All three options are expensive to maintain and easy to get wrong.

The Vault SSH pattern, and why teams don't adopt it

The path most security teams push for is HashiCorp Vault's SSH secrets engine. Vault becomes a certificate authority. Client hosts trust that CA. When Ansible or Terraform needs to connect, it asks Vault to sign a short-lived certificate for a specific user and set of hosts. The cert expires in minutes.

This works. It's also a lot:

  • A Vault cluster you have to run, back up, unseal, and monitor

  • A CA to bootstrap and roll

  • A trust configuration on every managed host (TrustedUserCAKeys in sshd_config)

  • An authentication path for the IaC tool to reach Vault (usually AppRole or a cloud auth method), which is itself a credential

  • Policy engineering to scope who can request certs for which hosts

For a platform team of ten, this is a meaningful ongoing investment. For a team of two, it's the reason SSH keys stay in ~/.ssh for another year.

The uncomfortable observation: Vault SSH solves the credential distribution problem by introducing a distributed system whose sole job is to distribute credentials. It's better than shared keys. It's not free.

What if the IaC tool didn't need a credential?

Here's the pattern change. Instead of managing the SSH credential lifecycle — issuance, distribution, rotation, revocation — you remove the credential from the IaC tool entirely.

Twingate's Identity Firewall is a Layer 7 gateway that sits in front of resources like SSH, HTTPS, and database connections. It authenticates the user through your IdP, enforces access policy at the connection layer, and proxies the traffic to the target host.

The architecture works like this:

  1. The machine running Ansible or Terraform has the Twingate Client installed

  2. The operator (or the CI service account) is authenticated to Twingate through the IdP

  3. When Ansible or Terraform initiates an SSH connection to a target hostname, the Client intercepts the DNS query, routes the traffic through a Connector deployed inside the target network, and the Identity Firewall enforces policy at the SSH layer

  4. No inbound firewall rules on the target host. No SSH key required on the IaC runner. No credential to rotate.

The target host still runs sshd. It still authenticates the connection. The difference is that authentication happens through the Identity Firewall's session, tied to the IdP identity, not through a static key file sitting on a runner.

Comparison: three ways to handle IaC SSH auth

Approach

Credential on runner?

Rotation model

Requires open SSH port?

Auditability

Static SSH key

Yes, long-lived

Manual, error-prone

Yes, or full VPN

Only what the target host logs

Vault-signed SSH cert

Yes, but short-lived

Automatic, minutes TTL

Yes, or full VPN

Vault issuance logs + host logs

Identity Firewall

No

Not applicable — no key exists

No, Connector is outbound-only

IdP session + Twingate connection logs + host logs

The third row is the interesting one. There is no key to rotate because there is no key. The credential lifecycle problem doesn't get better — it stops existing.

Setting this up for an Ansible or Terraform workflow

The setup is conceptually simple. The mechanics assume you have a Twingate account and an IdP already connected.

1. Deploy a Connector inside the target network

The Connector is a lightweight process that sits behind your firewall and makes only outbound connections to Twingate's infrastructure. Deploy at least two per Remote Network for high availability.

Deploy a Connector on a Linux host via Docker:

docker run -d \
  --name twingate-connector \
  --restart=always \
  --network=host \
  --sysctl net.ipv4.ip_forward=1 \
  --env TWINGATE_ACCESS_TOKEN="<access-token>" \
  --env TWINGATE_REFRESH_TOKEN="<refresh-token>

2. Register your SSH targets as Resources

In the Twingate admin console, add the hostname (or CIDR block) of each host Ansible or Terraform needs to reach. Assign the Resources to a Security Policy that requires IdP authentication and, if needed, device posture or MFA.

3. Install the Twingate Client on the runner

For a laptop operator, install the Twingate desktop client. For a CI runner, use the headless Linux client with a service account.

Install and start the headless client on a Linux CI runner:


4. Run Ansible or Terraform as normal

No changes to playbooks or Terraform configuration. Ansible still SSHes. Terraform's remote-exec still SSHes. The Client transparently routes traffic to the Connector, which proxies to the target host. The SSH connection itself now happens over a tunnel authenticated by the IdP.

You do still need a way for sshd to authenticate the connection at the OS level — that's outside Twingate's scope. Common patterns: use SSH certificates issued by your IdP or CA, use AWS SSM's AuthorizedKeysCommand to pull keys from IAM, or run the IaC tool as a specific service user with a locked-down keypair that never leaves the target-side infrastructure. The point is that the credential no longer needs to be on the runner or in a secrets manager the runner reads from.

What this actually changes

The concrete operational effects, in order of how much they matter:

  • CI runners and laptops stop being credential stores. A compromised runner does not leak a key that unlocks production. The IdP session is the credential, and it's bound to the device and user.

  • No inbound SSH port on target hosts. The Connector reaches out. Nothing reaches in. Public IP exposure for management SSH goes away.

  • Access is scoped per user, not per key. Revoking a person's access is an IdP action, not a scavenger hunt for key files.

  • Audit trail is coherent. Twingate logs the session with the IdP identity attached. You know which human ran which terraform apply against which host, and when.

  • Rotation stops being a project. There's no key. There's nothing to rotate.

The trade-off is honest. You are trusting a Zero Trust access provider to sit in the path. That's an architectural decision worth making deliberately. What you get back is the removal of a whole category of credential-management work that most teams have been quietly failing at for years.

Closing

For a deeper look at how Identity Firewall enforces access at the connection layer for SSH, HTTPS, and Kubernetes workloads, see the Identity Firewall documentation. For the Ansible and Terraform runner setup specifically, the Linux headless mode guide covers service account provisioning and CI integration.

New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.

Rapidly implement a modern Zero Trust network that is more secure and maintainable than VPNs.

/

IaC SSH Auth

Ansible and Terraform Still Need SSH Keys. That's a Problem You Can Fix

•

Product Marketing Engineer

•

Icons for infrastructure-as-code, a terminal, and an unlocked padlock on a dark grid background.

TL;DR: A look at how infrastructure-as-code tools handle SSH authentication, why the common patterns create long-term credential debt, and how Twingate Privileged Access for SSH removes SSH keys from the loop entirely.

Infrastructure-as-code solved the problem of describing what should exist. It did not solve the problem of how the tool doing the describing gets in the door.

Ansible SSHes into hosts. Terraform's remote-exec provisioner SSHes into hosts. Packer SSHes into build machines. Every one of these needs a credential from somewhere, and somewhere is where things get messy.

If you have ever inherited an Ansible codebase and gone looking for how it authenticates, you know the answer is usually one of three things: a key file on the operator's laptop, a key file baked into a CI runner, or a shared key sitting in a secrets manager that gets copied around. Occasionally, if the team has invested the time, it's a Vault SSH secrets engine issuing short-lived certificates. That last one is the "right" answer, and it's also why so few teams do it.

This post walks through the common SSH authentication patterns for Ansible and Terraform, what each actually costs to operate, and how routing IaC traffic through a Zero Trust access layer removes the credential problem instead of managing it.

The SSH problem IaC never solved

Ansible and Terraform were designed around SSH because SSH was what existed. Ansible connects to managed nodes over SSH and runs Python. Terraform providers mostly talk to cloud APIs, but the moment you need to touch a machine directly (remote-exec, file, Packer builds, kubeadm bootstrapping) you're back to SSH.

Neither tool has an opinion about where the SSH credential comes from. That's not a bug. It's a deliberate scope choice. The IaC tool assumes the operator has already figured out authentication. The result is that credential management gets pushed to whoever is running the tool, which in practice means it gets pushed nowhere until someone gets audited.

The three patterns that emerge look like this:

Pattern

Where the key lives

Rotation

Blast radius

Laptop key

~/.ssh/id_ed25519 on the operator's machine

When the operator leaves, maybe

Every host the operator can reach

CI runner key

Injected as a secret at pipeline start

Whenever someone remembers

Every host the pipeline targets

Vault-signed cert

Issued on demand by Vault SSH engine

Automatic, minutes-long TTL

Bounded by cert TTL

The Vault approach is architecturally sound. It's also a distributed system you now have to run to avoid distributing secrets. For a mid-sized platform team, that's a reasonable trade. For most teams, it's an aspiration on the roadmap that keeps slipping.

What Ansible's SSH authentication actually looks like

Ansible's connection to managed hosts is defined at the inventory or play level. In the default setup, you point it at a private key:

Run a playbook against a group of hosts using a local key:

Or, more commonly, the key path lives in ansible.cfg or a variable:

An inventory file specifying the SSH key per host group:

[web]

That key needs to exist wherever Ansible runs. On a laptop, that's an .ssh directory. In CI, it's a secret injected at runtime, usually written to a temporary file so ssh-agent or Ansible can find it. In an Ansible Tower or AWX setup, credentials are stored in the controller's database.

Every one of these places is a copy of the credential. Every copy is a rotation problem. When someone leaves the team, or when a laptop gets lost, the rotation involves generating a new key, deploying the public half to every managed host, and updating every copy of the private half. Most teams do the first step and skip the second because tracking down every copy is a research project.

Ansible does support alternatives — Kerberos, SSH certificates, connection plugins that use cloud provider SSM sessions — but adoption is uneven and each option comes with its own operational baggage.

What Terraform's SSH authentication actually looks like

Terraform's SSH exposure is narrower but structurally identical. It shows up in provisioner "remote-exec", provisioner "file", and any provider that reaches into a host (Packer via the Terraform provider, Consul, kubeadm modules).

A typical Terraform provisioner block:

resource "aws_instance" "app" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.medium"

  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file("~/.ssh/terraform_deploy")
    host        = self.private_ip
  }

  provisioner "remote-exec" {
    inline = [
      "sudo systemctl restart app",
    ]

The file("~/.ssh/terraform_deploy") line is the problem. That key has to exist on the machine running terraform apply. In a Terraform Cloud or Atlantis setup, it means the runner has the key. In a self-hosted setup, it means the CI runner has the key. Either way, the credential is now distributed across every place plans and applies can happen.

There's also the network problem. self.private_ip implies the Terraform runner is on the same network as the target instance, or reaches it through a VPN, or you've exposed SSH to the public internet with an IP allowlist. All three options are expensive to maintain and easy to get wrong.

The Vault SSH pattern, and why teams don't adopt it

The path most security teams push for is HashiCorp Vault's SSH secrets engine. Vault becomes a certificate authority. Client hosts trust that CA. When Ansible or Terraform needs to connect, it asks Vault to sign a short-lived certificate for a specific user and set of hosts. The cert expires in minutes.

This works. It's also a lot:

  • A Vault cluster you have to run, back up, unseal, and monitor

  • A CA to bootstrap and roll

  • A trust configuration on every managed host (TrustedUserCAKeys in sshd_config)

  • An authentication path for the IaC tool to reach Vault (usually AppRole or a cloud auth method), which is itself a credential

  • Policy engineering to scope who can request certs for which hosts

For a platform team of ten, this is a meaningful ongoing investment. For a team of two, it's the reason SSH keys stay in ~/.ssh for another year.

The uncomfortable observation: Vault SSH solves the credential distribution problem by introducing a distributed system whose sole job is to distribute credentials. It's better than shared keys. It's not free.

What if the IaC tool didn't need a credential?

Here's the pattern change. Instead of managing the SSH credential lifecycle — issuance, distribution, rotation, revocation — you remove the credential from the IaC tool entirely.

Twingate's Identity Firewall is a Layer 7 gateway that sits in front of resources like SSH, HTTPS, and database connections. It authenticates the user through your IdP, enforces access policy at the connection layer, and proxies the traffic to the target host.

The architecture works like this:

  1. The machine running Ansible or Terraform has the Twingate Client installed

  2. The operator (or the CI service account) is authenticated to Twingate through the IdP

  3. When Ansible or Terraform initiates an SSH connection to a target hostname, the Client intercepts the DNS query, routes the traffic through a Connector deployed inside the target network, and the Identity Firewall enforces policy at the SSH layer

  4. No inbound firewall rules on the target host. No SSH key required on the IaC runner. No credential to rotate.

The target host still runs sshd. It still authenticates the connection. The difference is that authentication happens through the Identity Firewall's session, tied to the IdP identity, not through a static key file sitting on a runner.

Comparison: three ways to handle IaC SSH auth

Approach

Credential on runner?

Rotation model

Requires open SSH port?

Auditability

Static SSH key

Yes, long-lived

Manual, error-prone

Yes, or full VPN

Only what the target host logs

Vault-signed SSH cert

Yes, but short-lived

Automatic, minutes TTL

Yes, or full VPN

Vault issuance logs + host logs

Identity Firewall

No

Not applicable — no key exists

No, Connector is outbound-only

IdP session + Twingate connection logs + host logs

The third row is the interesting one. There is no key to rotate because there is no key. The credential lifecycle problem doesn't get better — it stops existing.

Setting this up for an Ansible or Terraform workflow

The setup is conceptually simple. The mechanics assume you have a Twingate account and an IdP already connected.

1. Deploy a Connector inside the target network

The Connector is a lightweight process that sits behind your firewall and makes only outbound connections to Twingate's infrastructure. Deploy at least two per Remote Network for high availability.

Deploy a Connector on a Linux host via Docker:

docker run -d \
  --name twingate-connector \
  --restart=always \
  --network=host \
  --sysctl net.ipv4.ip_forward=1 \
  --env TWINGATE_ACCESS_TOKEN="<access-token>" \
  --env TWINGATE_REFRESH_TOKEN="<refresh-token>

2. Register your SSH targets as Resources

In the Twingate admin console, add the hostname (or CIDR block) of each host Ansible or Terraform needs to reach. Assign the Resources to a Security Policy that requires IdP authentication and, if needed, device posture or MFA.

3. Install the Twingate Client on the runner

For a laptop operator, install the Twingate desktop client. For a CI runner, use the headless Linux client with a service account.

Install and start the headless client on a Linux CI runner:


4. Run Ansible or Terraform as normal

No changes to playbooks or Terraform configuration. Ansible still SSHes. Terraform's remote-exec still SSHes. The Client transparently routes traffic to the Connector, which proxies to the target host. The SSH connection itself now happens over a tunnel authenticated by the IdP.

You do still need a way for sshd to authenticate the connection at the OS level — that's outside Twingate's scope. Common patterns: use SSH certificates issued by your IdP or CA, use AWS SSM's AuthorizedKeysCommand to pull keys from IAM, or run the IaC tool as a specific service user with a locked-down keypair that never leaves the target-side infrastructure. The point is that the credential no longer needs to be on the runner or in a secrets manager the runner reads from.

What this actually changes

The concrete operational effects, in order of how much they matter:

  • CI runners and laptops stop being credential stores. A compromised runner does not leak a key that unlocks production. The IdP session is the credential, and it's bound to the device and user.

  • No inbound SSH port on target hosts. The Connector reaches out. Nothing reaches in. Public IP exposure for management SSH goes away.

  • Access is scoped per user, not per key. Revoking a person's access is an IdP action, not a scavenger hunt for key files.

  • Audit trail is coherent. Twingate logs the session with the IdP identity attached. You know which human ran which terraform apply against which host, and when.

  • Rotation stops being a project. There's no key. There's nothing to rotate.

The trade-off is honest. You are trusting a Zero Trust access provider to sit in the path. That's an architectural decision worth making deliberately. What you get back is the removal of a whole category of credential-management work that most teams have been quietly failing at for years.

Closing

For a deeper look at how Identity Firewall enforces access at the connection layer for SSH, HTTPS, and Kubernetes workloads, see the Identity Firewall documentation. For the Ansible and Terraform runner setup specifically, the Linux headless mode guide covers service account provisioning and CI integration.

New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.

Rapidly implement a modern Zero Trust network that is more secure and maintainable than VPNs.

Ansible and Terraform Still Need SSH Keys. That's a Problem You Can Fix

•

Product Marketing Engineer

•

Icons for infrastructure-as-code, a terminal, and an unlocked padlock on a dark grid background.

TL;DR: A look at how infrastructure-as-code tools handle SSH authentication, why the common patterns create long-term credential debt, and how Twingate Privileged Access for SSH removes SSH keys from the loop entirely.

Infrastructure-as-code solved the problem of describing what should exist. It did not solve the problem of how the tool doing the describing gets in the door.

Ansible SSHes into hosts. Terraform's remote-exec provisioner SSHes into hosts. Packer SSHes into build machines. Every one of these needs a credential from somewhere, and somewhere is where things get messy.

If you have ever inherited an Ansible codebase and gone looking for how it authenticates, you know the answer is usually one of three things: a key file on the operator's laptop, a key file baked into a CI runner, or a shared key sitting in a secrets manager that gets copied around. Occasionally, if the team has invested the time, it's a Vault SSH secrets engine issuing short-lived certificates. That last one is the "right" answer, and it's also why so few teams do it.

This post walks through the common SSH authentication patterns for Ansible and Terraform, what each actually costs to operate, and how routing IaC traffic through a Zero Trust access layer removes the credential problem instead of managing it.

The SSH problem IaC never solved

Ansible and Terraform were designed around SSH because SSH was what existed. Ansible connects to managed nodes over SSH and runs Python. Terraform providers mostly talk to cloud APIs, but the moment you need to touch a machine directly (remote-exec, file, Packer builds, kubeadm bootstrapping) you're back to SSH.

Neither tool has an opinion about where the SSH credential comes from. That's not a bug. It's a deliberate scope choice. The IaC tool assumes the operator has already figured out authentication. The result is that credential management gets pushed to whoever is running the tool, which in practice means it gets pushed nowhere until someone gets audited.

The three patterns that emerge look like this:

Pattern

Where the key lives

Rotation

Blast radius

Laptop key

~/.ssh/id_ed25519 on the operator's machine

When the operator leaves, maybe

Every host the operator can reach

CI runner key

Injected as a secret at pipeline start

Whenever someone remembers

Every host the pipeline targets

Vault-signed cert

Issued on demand by Vault SSH engine

Automatic, minutes-long TTL

Bounded by cert TTL

The Vault approach is architecturally sound. It's also a distributed system you now have to run to avoid distributing secrets. For a mid-sized platform team, that's a reasonable trade. For most teams, it's an aspiration on the roadmap that keeps slipping.

What Ansible's SSH authentication actually looks like

Ansible's connection to managed hosts is defined at the inventory or play level. In the default setup, you point it at a private key:

Run a playbook against a group of hosts using a local key:

Or, more commonly, the key path lives in ansible.cfg or a variable:

An inventory file specifying the SSH key per host group:

[web]

That key needs to exist wherever Ansible runs. On a laptop, that's an .ssh directory. In CI, it's a secret injected at runtime, usually written to a temporary file so ssh-agent or Ansible can find it. In an Ansible Tower or AWX setup, credentials are stored in the controller's database.

Every one of these places is a copy of the credential. Every copy is a rotation problem. When someone leaves the team, or when a laptop gets lost, the rotation involves generating a new key, deploying the public half to every managed host, and updating every copy of the private half. Most teams do the first step and skip the second because tracking down every copy is a research project.

Ansible does support alternatives — Kerberos, SSH certificates, connection plugins that use cloud provider SSM sessions — but adoption is uneven and each option comes with its own operational baggage.

What Terraform's SSH authentication actually looks like

Terraform's SSH exposure is narrower but structurally identical. It shows up in provisioner "remote-exec", provisioner "file", and any provider that reaches into a host (Packer via the Terraform provider, Consul, kubeadm modules).

A typical Terraform provisioner block:

resource "aws_instance" "app" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.medium"

  connection {
    type        = "ssh"
    user        = "ubuntu"
    private_key = file("~/.ssh/terraform_deploy")
    host        = self.private_ip
  }

  provisioner "remote-exec" {
    inline = [
      "sudo systemctl restart app",
    ]

The file("~/.ssh/terraform_deploy") line is the problem. That key has to exist on the machine running terraform apply. In a Terraform Cloud or Atlantis setup, it means the runner has the key. In a self-hosted setup, it means the CI runner has the key. Either way, the credential is now distributed across every place plans and applies can happen.

There's also the network problem. self.private_ip implies the Terraform runner is on the same network as the target instance, or reaches it through a VPN, or you've exposed SSH to the public internet with an IP allowlist. All three options are expensive to maintain and easy to get wrong.

The Vault SSH pattern, and why teams don't adopt it

The path most security teams push for is HashiCorp Vault's SSH secrets engine. Vault becomes a certificate authority. Client hosts trust that CA. When Ansible or Terraform needs to connect, it asks Vault to sign a short-lived certificate for a specific user and set of hosts. The cert expires in minutes.

This works. It's also a lot:

  • A Vault cluster you have to run, back up, unseal, and monitor

  • A CA to bootstrap and roll

  • A trust configuration on every managed host (TrustedUserCAKeys in sshd_config)

  • An authentication path for the IaC tool to reach Vault (usually AppRole or a cloud auth method), which is itself a credential

  • Policy engineering to scope who can request certs for which hosts

For a platform team of ten, this is a meaningful ongoing investment. For a team of two, it's the reason SSH keys stay in ~/.ssh for another year.

The uncomfortable observation: Vault SSH solves the credential distribution problem by introducing a distributed system whose sole job is to distribute credentials. It's better than shared keys. It's not free.

What if the IaC tool didn't need a credential?

Here's the pattern change. Instead of managing the SSH credential lifecycle — issuance, distribution, rotation, revocation — you remove the credential from the IaC tool entirely.

Twingate's Identity Firewall is a Layer 7 gateway that sits in front of resources like SSH, HTTPS, and database connections. It authenticates the user through your IdP, enforces access policy at the connection layer, and proxies the traffic to the target host.

The architecture works like this:

  1. The machine running Ansible or Terraform has the Twingate Client installed

  2. The operator (or the CI service account) is authenticated to Twingate through the IdP

  3. When Ansible or Terraform initiates an SSH connection to a target hostname, the Client intercepts the DNS query, routes the traffic through a Connector deployed inside the target network, and the Identity Firewall enforces policy at the SSH layer

  4. No inbound firewall rules on the target host. No SSH key required on the IaC runner. No credential to rotate.

The target host still runs sshd. It still authenticates the connection. The difference is that authentication happens through the Identity Firewall's session, tied to the IdP identity, not through a static key file sitting on a runner.

Comparison: three ways to handle IaC SSH auth

Approach

Credential on runner?

Rotation model

Requires open SSH port?

Auditability

Static SSH key

Yes, long-lived

Manual, error-prone

Yes, or full VPN

Only what the target host logs

Vault-signed SSH cert

Yes, but short-lived

Automatic, minutes TTL

Yes, or full VPN

Vault issuance logs + host logs

Identity Firewall

No

Not applicable — no key exists

No, Connector is outbound-only

IdP session + Twingate connection logs + host logs

The third row is the interesting one. There is no key to rotate because there is no key. The credential lifecycle problem doesn't get better — it stops existing.

Setting this up for an Ansible or Terraform workflow

The setup is conceptually simple. The mechanics assume you have a Twingate account and an IdP already connected.

1. Deploy a Connector inside the target network

The Connector is a lightweight process that sits behind your firewall and makes only outbound connections to Twingate's infrastructure. Deploy at least two per Remote Network for high availability.

Deploy a Connector on a Linux host via Docker:

docker run -d \
  --name twingate-connector \
  --restart=always \
  --network=host \
  --sysctl net.ipv4.ip_forward=1 \
  --env TWINGATE_ACCESS_TOKEN="<access-token>" \
  --env TWINGATE_REFRESH_TOKEN="<refresh-token>

2. Register your SSH targets as Resources

In the Twingate admin console, add the hostname (or CIDR block) of each host Ansible or Terraform needs to reach. Assign the Resources to a Security Policy that requires IdP authentication and, if needed, device posture or MFA.

3. Install the Twingate Client on the runner

For a laptop operator, install the Twingate desktop client. For a CI runner, use the headless Linux client with a service account.

Install and start the headless client on a Linux CI runner:


4. Run Ansible or Terraform as normal

No changes to playbooks or Terraform configuration. Ansible still SSHes. Terraform's remote-exec still SSHes. The Client transparently routes traffic to the Connector, which proxies to the target host. The SSH connection itself now happens over a tunnel authenticated by the IdP.

You do still need a way for sshd to authenticate the connection at the OS level — that's outside Twingate's scope. Common patterns: use SSH certificates issued by your IdP or CA, use AWS SSM's AuthorizedKeysCommand to pull keys from IAM, or run the IaC tool as a specific service user with a locked-down keypair that never leaves the target-side infrastructure. The point is that the credential no longer needs to be on the runner or in a secrets manager the runner reads from.

What this actually changes

The concrete operational effects, in order of how much they matter:

  • CI runners and laptops stop being credential stores. A compromised runner does not leak a key that unlocks production. The IdP session is the credential, and it's bound to the device and user.

  • No inbound SSH port on target hosts. The Connector reaches out. Nothing reaches in. Public IP exposure for management SSH goes away.

  • Access is scoped per user, not per key. Revoking a person's access is an IdP action, not a scavenger hunt for key files.

  • Audit trail is coherent. Twingate logs the session with the IdP identity attached. You know which human ran which terraform apply against which host, and when.

  • Rotation stops being a project. There's no key. There's nothing to rotate.

The trade-off is honest. You are trusting a Zero Trust access provider to sit in the path. That's an architectural decision worth making deliberately. What you get back is the removal of a whole category of credential-management work that most teams have been quietly failing at for years.

Closing

For a deeper look at how Identity Firewall enforces access at the connection layer for SSH, HTTPS, and Kubernetes workloads, see the Identity Firewall documentation. For the Ansible and Terraform runner setup specifically, the Linux headless mode guide covers service account provisioning and CI integration.

New to Twingate? You can use Twingate for free for up to 5 users, request a personalized demo, or reach out to the team over on the Twingate subreddit.