Build. Configure.
Verify. Repeat.
Deploy a real Ubuntu server with Terraform, keep it configured with Ansible, and connect both in a reviewed pipeline—with no stored cloud access keys or open SSH port.
1. What the pipeline builds
Think of Terraform as the builder: network, firewall and computer. Ansible is the checklist that keeps the computer arranged correctly. GitHub Actions coordinates the two, but a person reviews the infrastructure plan before spending money or deleting anything.
This creates a separate lab, not an import of your previous server. One t3.micro Ubuntu 24.04 VM has an encrypted 8 GiB root disk. Only HTTP from your own public IPv4 /32 is allowed inbound. Management uses AWS Systems Manager (SSM) rather than SSH, so there is no key-path guessing and no port 22 rule.
The server remains in a public subnet with broad outbound access for management and package downloads. This is a temporary learning environment, not private networking, HA or production security. The sample HTTP page contains no secrets; do not use it for logins or personal data.
2. Get a complete starter project
Download the pipeline project ZIP
Extract it and place its contents at the root of a new private repository on branch main. Preserve the hidden .github directory and .gitignore. Do not put the whole project under an extra pipeline-lab folder.
terraform/
main.tf
.terraform.lock.hcl
backend.hcl.example
lab.tfvars.example
ansible/
site.yml
index.html.j2
requirements.yml
requirements.txt
scripts/inventory.py
iam/trust.json.example
iam/permissions.json.example
.github/actions/tools/action.yml
.github/workflows/pipeline.yml
.gitignore
README.mdThe GitHub runner is Ubuntu x86, regardless of whether your laptop is Windows or Linux. For the local fallback, Windows users use Ubuntu on WSL. Review every file before enabling automation; do not upload private credentials.
3. Prepare the private backend and SSM access
These prerequisites are created once by an authorized account administrator, outside this Terraform state. Do not make the pipeline grant itself administrative access.
- Create two dedicated S3 buckets in us-east-1 with globally unique names. Keep all public access blocked, use bucket-owner-enforced ownership, default SSE-S3/AES256 encryption and a TLS-only bucket policy.
- State bucket: enable versioning for recovery. Use the exact state key contoso-pipeline/terraform.tfstate. Enable native lock files through use_lockfile=true; no DynamoDB table is used here.
- Set a short lifecycle for contoso-pipeline/plans/ only, including noncurrent plan versions; do not accidentally expire state history. Transfer bucket: disable versioning and set a short lifecycle, such as one day, for abandoned temporary transfers.
- Create an EC2-trusted role and instance profile named contoso-pipeline-ssm with AmazonSSMManagedInstanceCore. The VM must reach AWS SSM/SSM Messages services and S3 over outbound HTTPS. No incoming SSM port is required.
- Confirm the GitHub deployment role may pass only that specific instance role to EC2. Do not give it IAM role-creation or unrestricted PassRole.
The Ansible SSM connection uploads module files through its dedicated transfer bucket using temporary access. Keep that bucket separate from versioned state: interrupted runs can leave transfers, and versioning can retain their contents. This exercise sends no secrets in its application page.
Reference: S3 backend locking and permissions and Ansible SSM transport.
4. Set up short-lived pipeline authentication
Have an administrator create or reuse the GitHub OIDC provider with URL https://token.actions.githubusercontent.com and audience sts.amazonaws.com. Replace all account, owner, repository and bucket placeholders in these examples. They are intentionally not usable as pasted.
The trust subjects match the two named GitHub environments, not a branch subject. Restrict both environments to main, review who can modify workflows, and protect main. If your organization customizes OIDC subjects, use its approved actual subject—not a wildcard workaround.
No AWS access key is stored in GitHub. OIDC provides a short-lived role session. The existing VM instance profile is separate from the pipeline role. Reference: GitHub OIDC for AWS.
5. Create the review gate and variables
Create contoso-plan and contoso-deploy under repository Environments. Both must allow only main. Configure required reviewers on contoso-deploy and test the gate before enabling deployments. The YAML references an environment; it does not create its protection rules.
Check GitHub’s current environment rules. Keep logs and plans private: even without passwords they contain infrastructure context.
| Repository variable | Your value |
|---|---|
| AWS_ACCOUNT_ID | Your sandbox account ID; the action rejects other accounts |
| AWS_ROLE_ARN | The approved GitHub OIDC role ARN |
| STATE_BUCKET | The private versioned state bucket |
| TRANSFER_BUCKET | The separate non-versioned transfer bucket |
| AMI_ID | A real pinned Canonical Ubuntu 24.04 amd64 image in us-east-1 |
| INSTANCE_PROFILE | contoso-pipeline-ssm, or your administrator’s approved profile name |
| VIEWER_IPV4_CIDR | Your current public IPv4 plus /32, not 203.0.113.10/32 |
Use the official Canonical image publisher and verify its region. Terraform checks publisher, image name and architecture; it does not chase a changing “latest” image. A later AMI edit can replace the VM, so review it deliberately.
6. Inspect the infrastructure blueprint
The workflow also runs two plan-only tests with a mocked AWS provider: restricted small-server settings and rejection of an open viewer network. These tests make no AWS API calls and do not prove actual account permissions, SSM connectivity or deployment success. See Terraform provider mocking.
Terraform owns these network and VM resources, but not the bootstrap buckets, OIDC role or instance profile. The user-data script only enables the management transport. Application installation remains in Ansible. Keep the lock file in Git so both jobs select the same provider; do not run init -upgrade during a reviewed deployment.
The viewer /32 is only for your laptop’s HTTP test. It is not the GitHub runner’s address; SSM does not need an inbound rule for that runner.
7. Inspect the desired-state checklist
The inventory script reads the exact instance ID from Terraform state. It generates a private SSM inventory rather than storing a personal address or key path. Ansible waits for connectivity, waits for cloud-init, checks Ubuntu, installs Nginx, writes the escaped template and starts the service.
The final assertions inspect the page from inside the VM. They prove application content, not outside network reachability. A healthy repeated run should converge without repeatedly changing the template or service; package-cache refreshes can still report changes.
The project pins an engine and collection baseline. Python transitive dependencies and the Session Manager download are not fully locked. Review updates, verify the AWS package signature, and pin reviewed package versions and full action commit SHAs before stronger production use.
8. Run a reviewed deployment
- Open Actions → Contoso Terraform and Ansible pipeline → Run workflow on main. First select plan. This validates code and writes a private saved plan; it does not apply infrastructure.
- Confirm only the dedicated VPC, subnet, gateway, routes, firewall and one VM are proposed. Check the account, pinned image, disk deletion behavior and inbound /32. Stop on replacements or unrelated resources.
- When ready, launch deploy. This produces its own fresh plan. Read that run’s plan log while apply_and_configure waits for the configured reviewer. A prior plan-only run does not authorize this new plan.
- Approve only after checking the exact current run. The second job verifies the private S3 plan’s SHA-256 and applies that saved plan, not an unseen recalculation.
- Ansible configures and verifies the resulting VM. From your allowed laptop, check the demo separately using http://YOUR-SERVER-IP/ or the curl command below.
curl -I http://YOUR-SERVER-IP/
curl http://YOUR-SERVER-IP/Expect HTTP 200 and the Contoso message. Do not bypass HTTPS/security warnings if your browser refuses plain HTTP; use the terminal demonstration instead. The workflow does not open HTTP to the world for the GitHub runner to test.
Concurrency prevents simultaneous runs of this workflow; S3 locks protect state operations. Another pipeline or local edit can still stale a saved plan. If Terraform rejects it, create and review a new run—not a force-unlock.
9. Make a change and prove the split
Edit page_message in ansible/site.yml, commit to main through review, and dispatch configure. It shows an infrastructure plan for context but intentionally does not apply it. Existing infrastructure is required; a fresh account cannot use configure as a substitute for deploy.
The page should change while the instance ID remains the same. If the infrastructure plan shows drift, handle that in a separate reviewed deploy. For infrastructure changes, edit Terraform or the reviewed variables and dispatch deploy; inspect replacement actions before approval.
If configuration fails after apply, the server still exists and charges may continue. Inspect the error and dispatch configure after fixing it, or deliberately review destroy. Cancelling a job is not rollback.
| Failure | First check |
|---|---|
| OIDC denied | Exact owner/repo/environment subject, audience and allowed account |
| S3 denied | Exact state, .tflock, plan and transfer permissions; bucket policy |
| SSM times out | Instance profile, agent/cloud-init logs, region, outbound route and AWS endpoints |
| Transfer fails | Bucket region, encryption, permissions and the installed plugin |
| Internal HTTP works, laptop fails | Current viewer /32, public IP, port 80 rule, browser HTTPS behavior |
| Plan is stale | Other state writers; create a fresh reviewed run |
10. A local reviewed pipeline alternative
If your GitHub plan cannot enforce the required private-repository review, use the same project locally. Windows: Ubuntu in WSL. Linux: Ubuntu terminal. Install Terraform 1.13.5, AWS CLI v2, Python/venv and AWS’s Session Manager plugin from official instructions. Use your administrator-approved short-lived AWS login and verify the account first.
Copy backend.hcl.example to backend.hcl and lab.tfvars.example to lab.tfvars, then replace every placeholder. Keep both local files private. Run from the project root:
aws sts get-caller-identity
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r ansible/requirements.txt
ansible-galaxy collection install -r ansible/requirements.yml
terraform -chdir=terraform init -backend-config=backend.hcl
terraform -chdir=terraform validate
terraform -chdir=terraform plan -var-file=lab.tfvars -out=review.tfplan
# STOP: read every action and confirm the account before applying.
terraform -chdir=terraform apply review.tfplan
export TRANSFER_BUCKET="REPLACE-WITH-YOUR-PRIVATE-TRANSFER-BUCKET"
python scripts/inventory.py
ansible-playbook -i inventory.generated.json ansible/site.ymlThe saved plan applies without another prompt. The comment is a real stopping point for you, not an automated approval mechanism. Do not mix local and CI changes concurrently or switch backend keys. Rerun just the inventory and playbook for configuration-only changes.
11. Review destruction and finish cleanup
Dispatch destroy on main and review the new destruction plan at the environment gate. It must delete only the dedicated pipeline VM and network. The root disk is configured to delete with the instance, so preserve wanted data first. Ansible does not run during destruction.
Local equivalent, from this project and its original backend:
aws sts get-caller-identity
terraform -chdir=terraform state list
terraform -chdir=terraform plan -var-file=lab.tfvars -destroy -out=cleanup.tfplan
# STOP: review each deletion before continuing.
terraform -chdir=terraform apply cleanup.tfplan
terraform -chdir=terraform state listVerify the instance, root disk and lab network are gone. Then separately inspect leftover transfers, plan objects and their versions. Retain state history until resource cleanup is confirmed. An administrator may remove the dedicated bootstrap buckets/profile/OIDC role only if nothing else uses them. Never empty a shared bucket or delete shared IAM resources.
Check delayed billing later. Stopping a VM, cancelling Actions or deleting a Git repository does not clean up AWS. Acceptance checks: correct account → reviewed plan → expected instance → passing Ansible assertions → laptop HTTP test → unchanged repeat configuration → confirmed teardown.