devops-bootcamp-project · setup guide
Provision a 3-node AWS stack with Terraform, configure it via Ansible over AWS SSM, and serve a Dockerized web app with Prometheus/Grafana monitoring behind Cloudflare.
What this repo runs
This project deploys (all in ap-southeast-1):
- pub-web — public EC2 (
t3.micro) serving a Dockerized nginx app on port 80, pointed at by a proxied Cloudflare A record. - pri-ctr — private EC2 acting as the Ansible control node.
- pri-mon — private EC2 running Prometheus, Grafana, and a
cloudflaredtunnel for remote access. - Networking — VPC with public/private subnets + NAT (
terraform/network.tf), security groups (terraform/security.tf). - S3 — remote Terraform state bucket + a bucket used by the SSM connection plugin.
- CI — a GitHub Actions workflow (
.github/workflows/deploy.yaml) that lints the Terraform on push.
Ansible connects to all nodes over AWS SSM (no public SSH), using an inventory file generated by terraform apply.
Architecture at a glance
flowchart TB
U["User / browser"] --> CF["Cloudflare
DNS · Pages · Tunnel"]
subgraph AWS["AWS · ap-southeast-1"]
subgraph VPC["VPC 10.0.0.0/16"]
subgraph PUB["Public subnet 10.0.0.0/24"]
WEB["pub-web — Dockerized nginx app
:80 + node_exporter"]
end
subgraph PRIV["Private subnet 10.0.1.0/24"]
CTR["pri-ctr — Ansible control node (venv)"]
MON["pri-mon — Prometheus · Grafana · cloudflared"]
end
end
ECR["ECR — devops-bootcamp-project/ship"]
S3["S3 — remote state + SSM transfer bucket"]
end
DOC["docs/index.html
bootcamp.h-feh.com (Cloudflare Pages)"]
PROM["Prometheus scrape targets :9100"]
CF -- "web.h-feh.com (proxied A record)" --> WEB
CF -- "monitoring.h-feh.com (tunnel)" --> MON
CF -. "bootcamp.h-feh.com (Pages)" .-> DOC
CTR -- "aws_ssm sessions (no public SSH)" --> WEB
CTR -- "aws_ssm sessions (no public SSH)" --> MON
CTR -. "session file transfer" .-> S3
WEB -. "docker pull" .-> ECR
WEB -- "node_exporter :9100" --> PROM
MON -- "node_exporter :9100" --> PROM
Rendered with Mermaid.js (loaded from CDN); the raw source is visible if the script is unavailable.
Repository layout
| Path | Purpose |
|---|---|
terraform/ | Modular Terraform: providers, VPC/network, security groups, EC2 instances, S3, Cloudflare DNS, and templates that render the Ansible inventory & Prometheus config. |
ansible/ | Playbooks, ansible.cfg, Docker Compose stack (Prometheus/Grafana/cloudflared), and Grafana provisioning dashboards. |
app/ | Multi-stage Docker build (app/Dockerfile) for the web app image. |
docs/ | This static landing page (hosted on Cloudflare Pages / GitHub Pages). |
.github/workflows/ | CI — currently only Terraform linting is active. |
Prerequisites
- AWS account with CLI credentials for
ap-southeast-1(the region is hardcoded). - Terraform 1.15+ (
terraform/providers.tfrequires>= 1.15). - On your local machine: AWS CLI v2 + SSM Session Manager plugin, Python 3 +
venv, and Ansible + boto3 (only theamazon.awscollection is needed locally — see step 6). - A Cloudflare zone with an API token and its Zone ID.
- IAM roles/instance profiles and an ECR repository that must already exist in your account — see IAM section and caveats.
The Ansible roles and other collections (geerlingguy.docker, community.docker, prometheus.prometheus) are not a local prerequisite — the bootstrap playbook installs them on the control node automatically.
IAM roles & permissions
Three IAM roles (referenced by Terraform as instance profiles in terraform/ec2.tf) must exist in your account so each node can only do its own job. Attach the managed policies plus the custom inline policy below to each role, then create an instance profile with the same name and add the role to it.
AmazonSSMManagedInstanceCore is on all three so the SSM agent runs and any node can be reached over aws_ssm without public SSH. The only broad grants (Resource: "*") are the SSM/EC2 read actions needed to establish SSM sessions; anything touching data is narrowed to a specific bucket or a single SSM parameter. Each breakdown below explains what its role must accomplish and therefore why those particular actions are allowed.ansible-control-node— attached topri-ctr(data.aws_iam_instance_profile.ctr-profile,ec2.tf:11-13).web-ecr— attached topub-web(data.aws_iam_instance_profile.web-profile,ec2.tf:15-17).monitoring— attached topri-mon(data.aws_iam_instance_profile.mon-profile,ec2.tf:19-21).
a. ansible-control-node (pri-ctr)
Managed policy: AmazonSSMManagedInstanceCore. Custom inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ssm:StartSession",
"ssm:SendCommand",
"ssm:GetConnectionStatus",
"ssm:DescribeInstanceInformation",
"ssm:DescribeSessions",
"ssm:TerminateSession",
"ec2:DescribeInstances",
"ec2:DescribeRegions"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Resource": [
"arn:aws:s3:::bootcamp-2026-proj-ssm",
"arn:aws:s3:::bootcamp-2026-proj-ssm/*"
]
}
]
}
ssm:StartSession/SendCommand, plus session/count/session-status reads to manage them) and to discover instances and regions (ec2:DescribeInstances/DescribeRegions), which is what the amazon.aws aws_ssm connection uses to resolve the inventory IDs in inventory.ini. Because the SSM connection transfers its payload (scripts, module files) through a dedicated bucket — the inventory sets ansible_aws_ssm_bucket_name — it also needs object read/write/delete on exactly that bucket (bootcamp-2026-proj-ssm), nothing else. In short: orchestrate sessions (broad read) + touch only its own transfer bucket.File to change if you use your own bucket: the SSM bucket ARN here must match the name in terraform/s3.tf.
b. monitoring (pri-mon)
Managed policy: AmazonSSMManagedInstanceCore. Custom inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeTags"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters"
],
"Resource": "arn:aws:ssm:ap-southeast-1:257289932779:parameter/devops-bootcamp-2026/tunnel-token"
}
]
}
cloudflared, so it gets ssm:GetParameter restricted to that single parameter ARN — never to the whole parameter store (that is the one secret these permissions touch). It also describes EC2 instances/tags (read-only) to identify the hosts it monitors or target with Prometheus config. It cannot start sessions, read other secrets, or touch S3.File to change for your own account: the account ID and region in the SSM parameter ARN, and the parameter path (ansible/playbook-mon-stack.yaml reads /devops-bootcamp-2026/tunnel-token).
c. web (pub-web, profile web-ecr)
Managed policies: AmazonSSMManagedInstanceCore + AmazonEC2ContainerRegistryReadOnly. No inline policy needed — the read-only ECR access lets this node authenticate and pull the application image.
devops-bootcamp-project/ship:latest (playbook-web-stack.yaml). Read-only registry access is exactly that — it cannot push images or read anything else. This is the simplest role on purpose; nothing about serving port 80 requires session, parameter, or S3 access.Quick start
Where you run each command matters: Terraform and the bootstrap playbook run from your local machine, but the web/monitoring playbooks are meant to run on the control node over an SSM session.
pip install ansible boto3 botocore and ansible-galaxy collection install amazon.aws inside a venv, plus the AWS CLI and SSM plugin. This is only to run the SSM-based playbook-setup-ansible.yaml once — it then installs the full Ansible stack on pri-ctr.# ---------- local machine ----------
cd terraform/
terraform init # connect to remote S3 state
terraform plan -out=tfplan
terraform apply tfplan # creates infra + generates ../ansible/inventory.ini
# bootstrap the control node (installs Ansible, roles & collections on pri-ctr)
cd ../ansible
ansible-playbook playbook-setup-ansible.yaml # targets: ctr
# ---------- control node (pri-ctr) over SSM ----------
aws ssm start-session --target <pri-ctr_id> # see: terraform output ssm_pri-ctr
cd /opt/ansible # playbooks copied here by the bootstrap
ansible-playbook playbook-mon-stack.yaml # targets: mon (run first)
ansible-playbook playbook-web-stack.yaml # targets: web
Full walkthrough
1. Set your credentials and variables
File to change: terraform/terraform.tfvars — create this yourself.
*.tfvars files are gitignored (.gitignore), so the repo ships without one. Copy the three variables that terraform/variables.tf declares:
# terraform/terraform.tfvars
cloudflare_api_token = "your_api_token_here" # Cloudflare > My Profile > API Tokens
cloudflare_zone_id = "your_zone_id_here" # Cloudflare > domain page > Overview
cloudflare_record = "web" # subdomain for the A record
terraform.tfvars in a fresh clone of the original environment holds a live token — generate your own.terraform apply also expects the following to already exist in your account (they are referenced, not created, by this repo): IAM instance profiles ansible-control-node, web-ecr, and monitoring with the policies in the IAM section (terraform/ec2.tf), plus your own ECR repository devops-bootcamp-project/ship.
2. Initialize Terraform
State lives in a remote S3 backend, so init needs working AWS credentials:
cd terraform/
terraform init
Note — file to change if you own this on your own account: the backend bucket bootcamp-2026-proj-hfe and the provider region are hardcoded in terraform/providers.tf, and the SSM bucket bootcamp-2026-proj-ssm is fixed in terraform/s3.tf.
3. Preview the infrastructure
terraform plan -out=tfplan
Review that the instance types, subnets, and security groups match your requirements before applying.
4. Apply
terraform apply tfplan
Provisions the VPC, security groups, S3 buckets, three EC2 instances, the Cloudflare DNS record, and renders two generated files used by Ansible:
ansible/inventory.ini— inventory with SSM connection settings (template:terraform/inventory.ini.tftpl).ansible/prometheus.yml— scrape targets resolved to node private IPs (template:terraform/prometheus.yml.tftpl).
The instance IDs in the inventory are from the current run, so re-running apply after infrastructure changes will refresh the file.
5. Check the outputs
terraform output
Shows the public/private IPs and ready-to-run aws ssm start-session commands for each node. Even the private nodes are reachable this way thanks to SSM.
6. Bootstrap the control node (run from your local machine)
playbook-setup-ansible.yaml (hosts: ctr) connects to the private control node over SSM and does the whole setup for you — you do not install Ansible parts on the control node by hand:
- creates a Python virtual environment (
/opt/ansible-venv) and installs Ansible + boto3/botocore in it; - installs the AWS CLI v2 and Session Manager plugin;
- copies
ansible.cfg,inventory.ini, the other playbooks,compose.yaml,prometheus.yml, andgrafana-provisioning/to/opt/ansible; - installs the roles and collections —
geerlingguy.docker,amazon.aws,community.docker,prometheus.prometheus— on the control node.
# must run FROM your local machine (the ctr node has no Ansible yet)
cd ansible/ # so ./inventory.ini resolves
ansible-playbook playbook-setup-ansible.yaml
To run this playbook you only need a local Ansible installation capable of the aws_ssm connection — i.e. pip install ansible boto3 botocore plus the amazon.aws collection and the SSM plugin. None of the app/monitoring roles are needed locally; they are installed on pri-ctr by this playbook. The local aws_ssm connection also relies on the SSM S3 bucket created by terraform apply (configured in the generated inventory.ini).
ansible.cfg defaults to inventory = inventory.ini, so playbooks can be run without -i as long as you run them from a directory containing that file.7. Bring up monitoring (run on the control node)
# from /opt/ansible on pri-ctr — run monitoring BEFORE the web stack
ansible-playbook playbook-mon-stack.yaml
playbook-mon-stack.yaml (hosts: mon) deploys Docker Compose (ansible/compose.yaml) with Prometheus, Grafana, and cloudflared. It reads the tunnel token from SSM parameter /devops-bootcamp-2026/tunnel-token. The generated prometheus.yml scrapes the web and monitoring nodes' node_exporter (port 9100). Grafana is auto-provisioned with dashboards from ansible/grafana-provisioning/.
Access Grafana through the Cloudflare Tunnel URL; Prometheus listens internally on :9090.
8. Deploy the web app (run on the control node)
Connect to the control node and run the web playbook there, after monitoring — not on your laptop:
# from the same /opt/ansible directory on pri-ctr
ansible-playbook playbook-web-stack.yaml
playbook-web-stack.yaml (hosts: web) installs Docker + node_exporter on the public server, logs into ECR, pulls devops-bootcamp-project/ship:latest, and runs it on port 80.
Then visit the app at http://<pub-web public IP> or the Cloudflare record (e.g. http://web.<your-domain>).
Cleanup
To tear everything down (run from terraform/):
terraform destroy
Note: this removes the Cloudflare DNS record, but ECR images and any manually created IAM profiles/S3 buckets are left behind.
Known caveats when cloning / forking
app/ contains only app/Dockerfile. The build runs npm ci and npm run build against package.json/`package-lock.json` and a source tree that do not exist in this repo, then serves dist/ from nginx. A fresh clone cannot build the image yet. To fix it: add your app source + package files under app/ (leave app/Dockerfile as the build definition — it references . as the context), then build and push the image to your own ECR repository in your account. The web playbook pulls devops-bootcamp-project/ship:latest from <your-account>.dkr.ecr.ap-southeast-1.amazonaws.com — either create a repo with that exact name or update the image_name var in ansible/playbook-web-stack.yaml to the repo you use.- Hardcoded regional/account values — backend bucket
bootcamp-2026-proj-hfe(terraform/providers.tf), SSM bucketbootcamp-2026-proj-ssm(terraform/s3.tf), regionap-southeast-1(terraform/providers.tf,terraform/inventory.tf, templates). Change these for your own account. - Pre-existing resources required — IAM instance profiles
ansible-control-node,web-ecr,monitoring(policies in the IAM section; referenced interraform/ec2.tf) and an ECR repository in your own account must already exist; Terraform only references them. - Secrets in SSM — the tunnel token is expected at SSM parameter
/devops-bootcamp-2026/tunnel-token(ansible/playbook-mon-stack.yaml). Create this yourself. - Generated files —
ansible/inventory.iniandansible/prometheus.ymlare outputs ofterraform applyand are gitignored / overwritten; don't hand-edit them. - CI is minimal —
.github/workflows/deploy.yamlonly lints Terraform (tflint); the test/build/deploy jobs are commented out pending the app source above.