AWS · Hands-on automation

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.

Not guaranteed free. Estimate EC2 running time, root-disk lifetime, public IPv4, S3 storage/requests and CI usage. Set a small budget, understand that alerts are delayed—not caps—and set a same-day cleanup timer. No NAT gateway, load balancer or paid monitoring add-on is provisioned.

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.

Project layout
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.md

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Sandbox policy, not production least privilege. The EC2 statement can manage the listed resource types across us-east-1. Use an isolated learning account, not a shared production account. An administrator should further scope resources/tags and separate plan, apply and configuration roles for team use. Session-ending permission is also account/region-scoped, not session-owner-only.

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.

Private-repository environment and review features depend on your GitHub plan. If required reviewers are unavailable, do not run this CI deployment without the gate. Use the local reviewed pipeline below or a suitably protected platform. Do not make a repository public simply to obtain a feature.

Check GitHub’s current environment rules. Keep logs and plans private: even without passwords they contain infrastructure context.

Repository variableYour value
AWS_ACCOUNT_IDYour sandbox account ID; the action rejects other accounts
AWS_ROLE_ARNThe approved GitHub OIDC role ARN
STATE_BUCKETThe private versioned state bucket
TRANSFER_BUCKETThe separate non-versioned transfer bucket
AMI_IDA real pinned Canonical Ubuntu 24.04 amd64 image in us-east-1
INSTANCE_PROFILEcontoso-pipeline-ssm, or your administrator’s approved profile name
VIEWER_IPV4_CIDRYour 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Your permitted laptop · independent HTTP check
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.

FailureFirst check
OIDC deniedExact owner/repo/environment subject, audience and allowed account
S3 deniedExact state, .tflock, plan and transfer permissions; bucket policy
SSM times outInstance profile, agent/cloud-init logs, region, outbound route and AWS endpoints
Transfer failsBucket region, encryption, permissions and the installed plugin
Internal HTTP works, laptop failsCurrent viewer /32, public IP, port 80 rule, browser HTTPS behavior
Plan is staleOther 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:

Ubuntu / WSL · prepare and review
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.yml

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

Ubuntu / WSL · explicitly reviewed destruction
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 list

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

Validation scope: this example is an educational project. Local Terraform and file checks do not prove a successful GitHub/AWS/SSM deployment. Follow these acceptance checks in your own authorized sandbox.