Self-Hosted Infrastructure as Code with Pulumi and Ansible: A Unified Approach
Quick Summary (TL;DR)
- Pulumi provisions cloud infrastructure using real programming languages (TypeScript, Python, Go).
- Ansible configures servers—cloud or on-prem—over SSH with idempotent playbooks.
- Combine them for a hybrid IaC workflow: Pulumi builds the boxes, Ansible fills them.
- Wire them together via stack outputs → Ansible inventory, or trigger Ansible from Pulumi using the
Commandprovider.- Keep state clean: Pulumi state in a remote backend, Ansible state (facts, Vault) in encrypted files.
Managing infrastructure across both cloud and on-premises environments is where most “single-tool” IaC strategies fall apart. Terraform is great at provisioning but weak at server configuration. Ansible is superb at configuration but not designed to create cloud resources declaratively. Pulumi sits in the middle—it can do both, but for traditional servers, Ansible still wins on ergonomics.
This guide shows you how to build a unified, idempotent, hybrid IaC pipeline using Pulumi for provisioning and Ansible for configuration.
Introduction to Unified Infrastructure as Code
Infrastructure as Code (IaC) has matured into two distinct disciplines:
- Provisioning — creating the resources themselves (VMs, networks, load balancers, databases).
- Configuration management — installing packages, managing files, starting services, and enforcing the desired state of a running machine.
Most teams pick one tool and stretch it. That works until you hit a hybrid environment: a cloud VPC on one side, a rack of bare-metal servers on the other, and a mandate to manage both from a single pipeline.
A unified approach means one repository, one CI/CD pipeline, one source of truth—even if two tools do the actual work.
Why Combine Pulumi and Ansible?
| Capability | Pulumi | Ansible |
|---|---|---|
| Cloud resource provisioning | ✅ Excellent | ⚠️ Limited (via modules) |
| On-prem server configuration | ⚠️ Requires APIs | ✅ Excellent (SSH, agentless) |
| Language model | TypeScript, Python, Go, C# | YAML playbooks |
| State tracking | ✅ Explicit state file | ❌ Stateless (idempotent by design) |
| Secrets | Pulumi ESC, Vault, cloud KMS | Ansible Vault |
| Best at | Declarative cloud APIs | Imperative-ish config drift correction |
Pulumi gives you strong typing, loops, conditionals, and real test frameworks. Ansible gives you agentless reach into anything with SSH and Python—including legacy systems that will never expose a REST API.
Combining them means: Pulumi defines what exists, Ansible defines what’s running on it.
Prerequisites and Setup
Before you start, ensure you have:
- Pulumi CLI (
curl -fsSL https://get.pulumi.com | sh) - Ansible (
pip install ansible-core) - A cloud account (AWS, Azure, or GCP) with credentials configured
- SSH access to at least one on-prem or self-hosted server
- A remote state backend (S3, Azure Blob, or Pulumi Cloud)
Initialize your project:
mkdir hybrid-iac && cd hybrid-iac
pulumi new aws-typescript
mkdir ansible && cd ansible
ansible-galaxy init roles/common
Your repository layout should look like this:
hybrid-iac/
├── index.ts # Pulumi program
├── Pulumi.yaml
├── ansible/
│ ├── inventory.ini
│ ├── site.yml
│ └── roles/
└── .github/workflows/ # CI/CD
Step 1: Provision Cloud Resources with Pulumi
Start with a simple Pulumi program that creates a VPC, a subnet, and two EC2 instances.
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const vpc = new aws.ec2.Vpc("hybrid-vpc", {
cidrBlock: "10.0.0.0/16",
enableDnsHostnames: true,
tags: { Environment: "hybrid" },
});
const subnet = new aws.ec2.Subnet("hybrid-subnet", {
vpcId: vpc.id,
cidrBlock: "10.0.1.0/24",
mapPublicIpOnLaunch: true,
});
const sg = new aws.ec2.SecurityGroup("hybrid-sg", {
vpcId: vpc.id,
ingress: [
{ protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] },
{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
],
egress: [{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] }],
});
const ami = aws.ec2.getAmi({
mostRecent: true,
owners: ["099720109477"], // Canonical
filters: [{ name: "name", values: ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] }],
});
const servers: aws.ec2.Instance[] = [];
for (let i = 0; i < 2; i++) {
servers.push(new aws.ec2.Instance(`web-${i}`, {
instanceType: "t3.micro",
ami: ami.then(a => a.id),
subnetId: subnet.id,
vpcSecurityGroupIds: [sg.id],
tags: { Name: `web-${i}`, Role: "webserver" },
}));
}
// Export IPs for Ansible inventory
export const webIps = servers.map(s => s.publicIp);
Run it:
pulumi up
Pulumi now outputs webIps—the public IPs your Ansible inventory will consume.
Step 2: Configure On-Premises Servers with Ansible
Ansible’s job is to take any server—cloud or bare-metal—and bring it to a known state. Here’s a playbook that installs Nginx and configures a static site on both the cloud instances and your on-prem host.
# ansible/site.yml
- hosts: webservers
become: true
vars:
site_root: /var/www/html
tasks:
- name: Ensure Nginx is installed
apt:
name: nginx
state: present
update_cache: true
- name: Deploy index page
copy:
dest: "{{ site_root }}/index.html"
content: "<h1>Managed by Ansible</h1>"
- name: Ensure Nginx is running
service:
name: nginx
state: started
enabled: true
Because every task uses an idempotent module (apt, copy, service), you can run this playbook a hundred times and the system converges to the same state.
Step 3: Integrating Pulumi and Ansible for Hybrid Workflows
This is where the unified approach pays off. You have two integration patterns:
Pattern A: Pulumi Outputs → Ansible Inventory (Recommended)
Generate the Ansible inventory dynamically from Pulumi outputs.
pulumi stack output webIps --json | jq -r '.[]' > ansible/cloud_hosts.txt
Then build a dynamic inventory file:
# ansible/inventory.ini
[webservers]
web-cloud-0 ansible_host=54.12.34.56
web-cloud-1 ansible_host=54.12.34.57
onprem-01 ansible_host=192.168.1.10
[webservers:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/id_rsa
Pattern B: Trigger Ansible from Pulumi
Use Pulumi’s Command provider to run Ansible as part of the deployment.
import { local } from "@pulumi/command";
const configure = new local.Command("run-ansible", {
create: "ansible-playbook -i ansible/inventory.ini ansible/site.yml",
triggers: [pulumi.output(webIps).apply(JSON.stringify)],
});
The triggers field ensures Ansible re-runs whenever the IPs change—keeping configuration in sync with provisioning.
The Flow
flowchart TD
A[Pulumi Program] -->|pulumi up| B[Cloud Provider API]
B --> C[VPC, Subnets, EC2]
C --> D[Stack Outputs: IPs]
D --> E{Integration Pattern}
E -->|Pattern A| F[Dynamic Ansible Inventory]
E -->|Pattern B| G[Pulumi Command Provider]
F --> H[ansible-playbook]
G --> H
H --> I[On-Prem Servers]
H --> J[Cloud Servers]
I --> K[Configured Fleet]
J --> K
Step 4: Managing State and Idempotency
Idempotency is the property that running the same operation twice produces the same result. Both tools support it, but they track state differently.
Pulumi State
Pulumi maintains an explicit state file. Store it remotely:
pulumi login s3://my-pulumi-state
Never commit state to Git. Use a locking backend (S3 + DynamoDB, or Pulumi Cloud) to prevent concurrent runs from corrupting state.
Ansible State
Ansible is stateless—it reads the current machine state each run and only applies changes when needed. This makes it naturally idempotent, but you must:
- Use modules, not raw
shell/command, whenever possible. - Add
creates:orchanged_when:guards to shell tasks. - Keep facts fresh (
gather_facts: true) so conditionals work correctly.
Secrets
| Secret Type | Tool | Storage |
|---|---|---|
| Cloud credentials | Pulumi | ESC / cloud KMS |
| SSH keys | Ansible | Ansible Vault |
| API tokens | Both | HashiCorp Vault |
| DB passwords | Pulumi → Ansible | Pulumi secret output → env var |
Pulumi secrets flow into Ansible via environment variables:
export const dbPassword = pulumi.secret("super-secret-value");
- name: Configure app with DB password
template:
src: config.j2
dest: /etc/app/config.yml
vars:
db_password: "{{ lookup('env', 'DB_PASSWORD') }}"
Comparison: Pulumi vs. Ansible vs. Terraform
| Feature | Pulumi | Ansible | Terraform |
|---|---|---|---|
| Language | TypeScript, Python, Go, C# | YAML | HCL |
| Provisioning | ✅ Native | ⚠️ Via modules | ✅ Native |
| Config management | ⚠️ Limited | ✅ Native | ❌ Not designed for it |
| Agentless | N/A | ✅ SSH | N/A |
| State | Explicit (backend) | None | Explicit (backend) |
| Learning curve | Medium (needs a language) | Low | Medium |
| Hybrid cloud + on-prem | ✅ With Ansible | ✅ With Pulumi | ⚠️ Requires extra tooling |
| Ideal role | Provisioner | Configurator | Provisioner |
Verdict: Use Pulumi + Ansible for hybrid environments. Use Terraform + Ansible if your team already standardizes on HCL.
Best Practices for Hybrid IaC
- One repo, two tools. Keep Pulumi code and Ansible playbooks versioned together so a single PR reflects an end-to-end change.
- Never hardcode IPs. Always derive Ansible inventory from Pulumi outputs.
- Use
pulumi up --diffin CI to review changes before applying. - Run Ansible with
--checkin pull requests to preview configuration drift. - Separate concerns. Pulumi should not install packages; Ansible should not create VPCs.
- Tag everything. Consistent tags (
Environment,Role,Owner) let Ansible target hosts by role rather than by hardcoded lists. - Encrypt secrets at rest. Pulumi ESC for cloud secrets, Ansible Vault for playbook variables.
- Test idempotency. Run playbooks twice in CI; the second run should report zero changes.
A minimal CI pipeline looks like:
sequenceDiagram
participant Dev as Developer
participant CI as CI Pipeline
participant Pulumi as Pulumi Backend
participant Ansible as Ansible Runner
participant Infra as Infrastructure
Dev->>CI: Push PR
CI->>Pulumi: pulumi preview
Pulumi-->>CI: Diff
CI->>Ansible: ansible-playbook --check
Ansible-->>CI: Drift report
Dev->>CI: Merge
CI->>Pulumi: pulumi up
Pulumi->>Infra: Provision resources
CI->>Ansible: ansible-playbook site.yml
Ansible->>Infra: Configure servers
Infra-->>CI: Converged
Conclusion
Pulumi and Ansible are not competitors—they’re complements. Pulumi’s strength is declarative cloud provisioning in real programming languages; Ansible’s strength is agentless, idempotent configuration of any server that speaks SSH. Together they give you a single pipeline that spans cloud and on-premises infrastructure without forcing either tool to do something it wasn’t designed for.
Start small: provision one VPC with Pulumi, wire the outputs into an Ansible inventory, and run a single playbook. Once that loop is stable, expand it into CI, add Vault for secrets, and let the pipeline drive every change.
FAQ
What is the main benefit of using Pulumi and Ansible together? Pulumi excels at provisioning and managing cloud infrastructure using general-purpose programming languages, while Ansible is ideal for configuring and managing on-premises servers and applications. Combining them provides a unified workflow for hybrid environments, leveraging the strengths of both tools.
Can I use Pulumi to manage on-premises servers? Pulumi can manage on-premises resources if they are exposed via APIs (e.g., VMware, OpenStack), but for configuration management of traditional servers (e.g., installing packages, managing files), Ansible is more suitable due to its agentless, SSH-based approach.
How do I ensure idempotency when combining Pulumi and Ansible? Both Pulumi and Ansible are idempotent by design. Pulumi tracks state and only makes changes when necessary. Ansible modules are idempotent, meaning they can be run multiple times without changing the system if it’s already in the desired state. Ensure your playbooks and Pulumi code follow idempotent patterns.
What are some common use cases for hybrid IaC with Pulumi and Ansible? Common use cases include deploying applications that span cloud and on-premises environments, managing legacy systems alongside cloud-native services, and implementing disaster recovery setups where on-premises serves as a fallback.
How do I handle secrets management in a unified Pulumi and Ansible workflow? Use Pulumi’s secret management (e.g., Pulumi ESC, AWS Secrets Manager, HashiCorp Vault) to store sensitive data. For Ansible, use Ansible Vault to encrypt sensitive variables. Integrate them by having Pulumi output secrets that Ansible can consume via environment variables or encrypted files.
Is it possible to trigger Ansible playbooks from Pulumi?
Yes, you can use Pulumi’s Command provider or dynamic providers to execute Ansible playbooks as part of your Pulumi deployment. This allows you to orchestrate both provisioning and configuration in a single workflow.