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

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

PathPurpose
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

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.

Why these permissions. The design follows least-privilege: one role per node, each scoped to exactly one responsibility. 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.

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/*"
      ]
    }
  ]
}
Why. This node is the Ansible control plane, so it needs to open SSM sessions and push commands to the other nodes (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"
    }
  ]
}
Why. This node only consumes two things. It reads the Cloudflare tunnel token for 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.

Why. The web server's only interaction with AWS beyond being managed is pulling containers: Docker authenticates to ECR and pulls 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.

One-time local setup (bootstrap prerequisite): 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
Security: never commit this file. The on-disk 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:

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:

# 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).

The 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

Web app source is missing. 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.