diff --git a/.dockerignore b/.dockerignore
index 5635d29..4ed9321 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,9 +1,25 @@
-.venv
-.pytest-cache
-.vscode
-.coverage
-dpaste.db
-build
-dist
-dpaste.egg-info
+.git
+.github
node_modules
+*.pyc
+__pycache__
+.pytest_cache
+.tox
+*.egg-info
+dist
+build
+.venv
+venv
+*.sqlite
+Dockerfile*
+docker-compose*
+.dockerignore
+.travis.yml
+.gitattributes
+docs
+terraform
+monitoring
+scripts
+*.md
+!setup.cfg
+!README.md
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..27efceb
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,13 @@
+* text=auto
+*.py text eol=lf
+*.cfg text eol=lf
+*.txt text eol=lf
+*.md text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.sh text eol=lf
+*.json text eol=lf
+Dockerfile text eol=lf
+Makefile text eol=lf
+*.tf text eol=lf
+*.hcl text eol=lf
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 0000000..ef39b2b
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,177 @@
+# ============================================================
+# CloudPulse - CD pipeline
+# Build -> Trivy gate -> push to ECR -> deploy to EC2 via SSM Run Command
+# -> HTTPS smoke test. Runs on merges to master that change the app,
+# the image or the deploy script; can also be started manually.
+# ============================================================
+name: CD
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - "dpaste/**"
+ - "client/**"
+ - "Dockerfile.hardened"
+ - "setup.py"
+ - "setup.cfg"
+ - "package.json"
+ - "package-lock.json"
+ - "scripts/deploy.sh"
+ - ".github/workflows/cd.yml"
+ workflow_dispatch:
+
+# Never run two deployments at once; queue instead of cancelling one mid-way
+concurrency:
+ group: cd-production
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ AWS_REGION: us-east-1
+ ECR_REPOSITORY: cloudpulse
+ INSTANCE_NAME: cloudpulse-server
+
+jobs:
+ build-scan-push:
+ name: Build, scan, push
+ runs-on: ubuntu-latest
+ outputs:
+ image_tag: ${{ steps.meta.outputs.tag }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Image tag = short commit SHA
+ id: meta
+ run: echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
+
+ # AWS Academy session credentials (OIDC is blocked in the Learner Lab).
+ # They expire with the lab session; refreshed by scripts/refresh-github-aws-secrets.ps1
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: Log in to Amazon ECR
+ id: ecr
+ uses: aws-actions/amazon-ecr-login@v2
+
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Build image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.hardened
+ load: true
+ push: false
+ provenance: false
+ sbom: false
+ tags: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
+
+ # Same policy as CI: fail on fixable CRITICAL/HIGH. Nothing is pushed if this fails.
+ - name: Trivy scan (release gate)
+ uses: aquasecurity/trivy-action@master
+ with:
+ image-ref: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
+ format: table
+ exit-code: "1"
+ severity: CRITICAL,HIGH
+ ignore-unfixed: true
+ trivyignores: .trivyignore
+
+ # ECR tags are immutable: a re-run for the same commit must not fail on push
+ - name: Push image to ECR
+ run: |
+ TAG="${{ steps.meta.outputs.tag }}"
+ if aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag="$TAG" >/dev/null 2>&1; then
+ echo "Tag $TAG already exists in ECR (immutable) - skipping push"
+ else
+ docker push "${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:$TAG"
+ fi
+
+ deploy:
+ name: Deploy to EC2 via SSM
+ needs: build-scan-push
+ runs-on: ubuntu-latest
+ environment:
+ name: production
+ url: ${{ steps.deploy.outputs.app_url }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: Deploy via SSM Run Command
+ id: deploy
+ env:
+ IMAGE_TAG: ${{ needs.build-scan-push.outputs.image_tag }}
+ run: |
+ set -euo pipefail
+ INSTANCE_ID=$(aws ec2 describe-instances \
+ --filters "Name=tag:Name,Values=${INSTANCE_NAME}" "Name=instance-state-name,Values=running" \
+ --query "Reservations[0].Instances[0].InstanceId" --output text)
+ if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" = "None" ]; then
+ echo "::error::No running instance tagged ${INSTANCE_NAME}"; exit 1
+ fi
+ PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
+ --query "Reservations[0].Instances[0].PublicIpAddress" --output text)
+ echo "Deploying tag ${IMAGE_TAG} to ${INSTANCE_ID} (${PUBLIC_IP})"
+
+ # Ship this commit's deploy.sh with the command, so the instance
+ # always runs the reviewed version from Git.
+ SCRIPT_B64=$(base64 -w0 scripts/deploy.sh)
+ jq -n --arg b64 "$SCRIPT_B64" --arg tag "$IMAGE_TAG" '{commands: [
+ "set -e",
+ "echo \($b64) | base64 -d > /opt/cloudpulse/deploy.sh",
+ "chmod 0755 /opt/cloudpulse/deploy.sh",
+ "/opt/cloudpulse/deploy.sh \($tag)"
+ ]}' > ssm-params.json
+
+ CMD_ID=$(aws ssm send-command \
+ --instance-ids "$INSTANCE_ID" \
+ --document-name AWS-RunShellScript \
+ --comment "CloudPulse deploy ${IMAGE_TAG}" \
+ --parameters file://ssm-params.json \
+ --query Command.CommandId --output text)
+ echo "SSM command ID: ${CMD_ID}"
+
+ # Poll until the command finishes (max 5 minutes)
+ STATUS=Pending
+ for _ in $(seq 1 60); do
+ sleep 5
+ STATUS=$(aws ssm get-command-invocation --command-id "$CMD_ID" \
+ --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending)
+ case "$STATUS" in Pending|InProgress|Delayed) continue ;; *) break ;; esac
+ done
+
+ echo "----- deploy.sh output -----"
+ aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" \
+ --query StandardOutputContent --output text || true
+ echo "----- stderr -----"
+ aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" \
+ --query StandardErrorContent --output text || true
+ echo "SSM status: ${STATUS}"
+ if [ "$STATUS" != "Success" ]; then
+ echo "::error::Deployment failed with status ${STATUS}"; exit 1
+ fi
+
+ echo "app_url=https://${PUBLIC_IP//./-}.sslip.io" >> "$GITHUB_OUTPUT"
+
+ - name: Smoke test over HTTPS
+ env:
+ APP_URL: ${{ steps.deploy.outputs.app_url }}
+ run: |
+ curl -sS --fail --retry 10 --retry-delay 5 --retry-all-errors \
+ -o /dev/null -w "GET / -> HTTP %{http_code} in %{time_total}s\n" "${APP_URL}/"
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fc56319
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,66 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install dependencies
+ run: pip install -e ".[dev]"
+
+ - name: Run tests with coverage
+ run: pytest dpaste/ --tb=short -q
+
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.10"
+
+ - name: Install ruff
+ run: pip install ruff
+
+ - name: Run linter
+ run: ruff check dpaste/
+ continue-on-error: true
+
+ docker-build-scan:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build hardened image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.hardened
+ push: false
+ load: true
+ tags: cloudpulse:ci-${{ github.sha }}
+
+ - name: Run Trivy vulnerability scanner
+ uses: aquasecurity/trivy-action@master
+ with:
+ image-ref: cloudpulse:ci-${{ github.sha }}
+ format: table
+ exit-code: 1
+ severity: CRITICAL,HIGH
+ ignore-unfixed: true
+ trivyignores: .trivyignore
\ No newline at end of file
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml.disabled
similarity index 100%
rename from .github/workflows/docker.yml
rename to .github/workflows/docker.yml.disabled
diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml
new file mode 100644
index 0000000..2f38b97
--- /dev/null
+++ b/.github/workflows/terraform.yml
@@ -0,0 +1,161 @@
+# ============================================================
+# CloudPulse - Terraform pipeline
+# PR: fmt -> validate -> Checkov IaC scan -> plan (shown in the run summary)
+# Merge: plan -> MANUAL APPROVAL (environment "infrastructure") -> apply the exact saved plan
+# ============================================================
+name: Terraform
+
+on:
+ pull_request:
+ branches: [master]
+ paths:
+ - "terraform/**"
+ - ".github/workflows/terraform.yml"
+ push:
+ branches: [master]
+ paths:
+ - "terraform/**"
+ - ".github/workflows/terraform.yml"
+ workflow_dispatch:
+
+# One Terraform run at a time (the S3 lock protects state; this avoids runs colliding on it)
+concurrency:
+ group: terraform
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ TF_VERSION: "1.16.2"
+ TF_IN_AUTOMATION: "true"
+ TF_INPUT: "false"
+ AWS_REGION: us-east-1
+ TF_VAR_allowed_ssh_cidr: ${{ secrets.TF_VAR_ALLOWED_SSH_CIDR }}
+
+defaults:
+ run:
+ working-directory: terraform
+
+jobs:
+ static-checks:
+ name: fmt, validate, Checkov
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: ${{ env.TF_VERSION }}
+ terraform_wrapper: false
+
+ - name: terraform fmt
+ run: terraform fmt -check -recursive -diff
+
+ - name: terraform validate
+ run: |
+ terraform init -backend=false
+ terraform validate
+
+ # Fails on any finding that is neither fixed nor skipped with a justification in the code
+ - name: Checkov IaC security scan
+ uses: bridgecrewio/checkov-action@v12
+ with:
+ directory: terraform
+ framework: terraform
+ quiet: true
+
+ plan:
+ name: Plan
+ needs: static-checks
+ runs-on: ubuntu-latest
+ outputs:
+ has_changes: ${{ steps.plan.outputs.has_changes }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: ${{ env.TF_VERSION }}
+ terraform_wrapper: false
+
+ # AWS Academy session credentials (OIDC is blocked in the Learner Lab)
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: terraform init (S3 backend)
+ run: terraform init
+
+ - name: terraform plan
+ id: plan
+ run: |
+ set +e
+ terraform plan -out=tfplan -no-color -lock-timeout=120s -detailed-exitcode > plan.txt 2>&1
+ code=$?
+ set -e
+ cat plan.txt
+ if [ "$code" -eq 1 ]; then exit 1; fi
+ if [ "$code" -eq 2 ]; then echo "has_changes=true" >> "$GITHUB_OUTPUT"; else echo "has_changes=false" >> "$GITHUB_OUTPUT"; fi
+
+ - name: Plan summary
+ run: |
+ {
+ echo "## Terraform plan"
+ echo '```'
+ grep -E '^(Plan:|No changes)|^ # ' plan.txt || true
+ echo '```'
+ echo "Full plan
"
+ echo
+ echo '```'
+ cat plan.txt
+ echo '```'
+ echo " "
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ # The apply job applies exactly this reviewed plan (short retention: plan files can contain sensitive values)
+ - name: Upload plan
+ if: github.event_name != 'pull_request' && steps.plan.outputs.has_changes == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: tfplan
+ path: terraform/tfplan
+ retention-days: 1
+
+ apply:
+ name: Apply (manual approval)
+ needs: plan
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && needs.plan.outputs.has_changes == 'true'
+ runs-on: ubuntu-latest
+ environment: infrastructure
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: ${{ env.TF_VERSION }}
+ terraform_wrapper: false
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }}
+ aws-region: ${{ env.AWS_REGION }}
+
+ - name: terraform init (S3 backend)
+ run: terraform init
+
+ - name: Download reviewed plan
+ uses: actions/download-artifact@v4
+ with:
+ name: tfplan
+ path: terraform
+
+ - name: terraform apply (exact saved plan)
+ run: terraform apply -no-color -lock-timeout=120s tfplan
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b69a35d..bd967a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,6 @@ dpaste/static/
dpaste.egg-info
dpaste.sqlite
node_modules
-**/__pycache__/
\ No newline at end of file
+**/__pycache__/
+# Generated monitoring target (changes with the EC2 IP)
+monitoring/prometheus/targets.d/*.yml
diff --git a/.trivyignore b/.trivyignore
new file mode 100644
index 0000000..6c51fb1
--- /dev/null
+++ b/.trivyignore
@@ -0,0 +1,8 @@
+# Python vulnerabilities vendored inside setuptools — cannot be
+# independently upgraded without forking the base image's Python.
+# These are build-tool internals, not application runtime code.
+# Tracked: will be resolved when python:3.10-slim updates setuptools.
+CVE-2026-23949
+CVE-2026-24049
+CVE-2025-47273
+GHSA-6v7p-g79w-8964
\ No newline at end of file
diff --git a/Dockerfile.hardened b/Dockerfile.hardened
new file mode 100644
index 0000000..e633a69
--- /dev/null
+++ b/Dockerfile.hardened
@@ -0,0 +1,80 @@
+# ============================================================
+# CloudPulse — Hardened multi-stage Dockerfile
+# Original application: dpaste (MIT License, DarrenOfficial)
+# Infrastructure/containerization: Rayen Mabrouk
+# ============================================================
+
+# Stage 1: Build static files (CSS/JS) with Node
+FROM node:lts-slim AS staticfiles
+
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
+COPY package.json package-lock.json Makefile ./
+RUN npm ci --ignore-scripts
+COPY client ./client
+RUN mkdir -p dpaste/static && make css && make js
+
+# Stage 2: Build Python application
+FROM python:3.10-slim AS build
+
+WORKDIR /app
+
+# Install build dependencies needed for compiling Python packages
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends gcc libpq-dev && \
+ rm -rf /var/lib/apt/lists/*
+
+RUN pip install --no-cache-dir -U pip
+
+# Copy static files from Stage 1
+COPY --from=staticfiles /app /app/
+
+# Install Python dependencies
+COPY setup.py setup.cfg ./
+COPY dpaste/__init__.py dpaste/
+RUN pip install --no-cache-dir -e .[production]
+
+# Copy application code
+COPY . .
+
+# Collect static files
+RUN python manage.py collectstatic --noinput
+
+# Clean up build artifacts not needed at runtime
+RUN rm -rf node_modules
+
+# Stage 3: Production runtime
+FROM python:3.10-slim AS runtime
+
+# Install runtime dependencies and curl for health check
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends libpq5 curl libexpat1 && \
+ rm -rf /var/lib/apt/lists/*
+
+# Create non-root user
+RUN groupadd -r dpaste && useradd -r -g dpaste -d /app -s /sbin/nologin dpaste
+
+WORKDIR /app
+
+# Copy the entire app with installed packages from build stage
+COPY --from=build /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
+COPY --from=build /usr/local/bin /usr/local/bin
+COPY --from=build /app /app
+
+# Create directory for SQLite database and set permissions
+RUN mkdir -p /data && chown -R dpaste:dpaste /app /data
+
+ENV PORT=8000
+ENV DATABASE_URL=sqlite:////data/dpaste.sqlite
+
+# Switch to non-root user
+USER dpaste
+
+EXPOSE ${PORT}
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD curl -f http://localhost:${PORT}/ || exit 1
+
+# Use exec form for proper signal handling
+CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py pyuwsgi --http=:${PORT} --logger file:/dev/null"]
\ No newline at end of file
diff --git a/README.md b/README.md
index ee7f98b..f01a8ad 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,116 @@
-Dpaste
+# CloudPulse
+
+**Taking an existing web application and running it on AWS the way a Cloud/DevOps team would:** containerised, provisioned with Terraform, delivered by CI/CD, secured, and monitored.
+
+[](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/ci.yml)
+[](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/cd.yml)
+[](https://github.com/rayenmabrouk/cloudpulse/actions/workflows/terraform.yml)
+
+> **Live demo:** the app runs in an AWS Academy Learner Lab, which stops the server between lab sessions and gives it a new public IP each time, so there is no permanent URL. The [evidence](#evidence) section shows the running system.
+
---
-
-[](https://github.com/DarrenOfficial/dpaste/actions/workflows/python.yml)
-[](https://hub.docker.com/r/darrenofficial/dpaste)
-
-----
+## The application is not mine
+
+The workload is **[dpaste](https://github.com/DarrenOfficial/dpaste)** (v3.5, MIT License), a Django pastebin created by Martin Mahner and Darren Nathanael. **I did not write the application code** (`dpaste/`, `client/`, `manage.py`, `setup.*`, `package*.json`, the original `Dockerfile` and `docker-compose.yml`). The original README is kept in [`docs/upstream-dpaste-README.md`](docs/upstream-dpaste-README.md).
+
+That is deliberate: a Cloud/DevOps engineer usually receives an application from a development team and builds everything around it. This repository is that "everything around it".
+
+## What I built
+
+| Area | What | Where |
+|---|---|---|
+| **Container** | Hardened 3-stage Dockerfile: Node build stage for static assets, Python build stage, slim runtime; non-root `dpaste` user; health check. Measured: **369 MB** on disk, **~77 MB** compressed in ECR | `Dockerfile.hardened`, `.dockerignore`, `.trivyignore` |
+| **Infrastructure as Code** | Terraform, 3 modules (networking, compute, monitoring): VPC, public subnet, IGW, security groups, EC2, ECR, SSM parameter, CloudWatch log group and alarms | `terraform/` |
+| **Remote state** | S3 backend: versioned, encrypted, public access blocked, TLS-only bucket policy, S3-native state locking. Bucket bootstrapped outside Terraform | `terraform/backend.tf`, `scripts/bootstrap-tfstate.ps1` |
+| **Deployment** | Deploy script run on the instance: pulls the image from ECR with the instance role, injects the secret from SSM, health-checks, and **rolls back automatically** to the previous image on failure | `scripts/deploy.sh` |
+| **HTTPS** | Caddy reverse proxy with automatic Let's Encrypt certificates (sslip.io hostname); the app port is not exposed publicly | `scripts/deploy.sh` |
+| **CI** | Tests (pytest), lint (ruff), image build + Trivy scan on every PR | `.github/workflows/ci.yml` |
+| **CD** | On merge: build -> Trivy gate -> push to ECR -> deploy via **SSM Run Command** (no SSH keys in CI) -> HTTPS smoke test | `.github/workflows/cd.yml` |
+| **Infra pipeline** | On PR: `fmt` -> `validate` -> **Checkov** -> `plan`. On merge: plan -> **manual approval** -> apply the exact saved plan | `.github/workflows/terraform.yml` |
+| **Operations** | Daily expired-snippet cleanup (systemd timer), ECR credential helper (no registry token on disk), branch protection | `scripts/deploy.sh` |
+| **Monitoring** | Prometheus + blackbox exporter probing local and production (availability, latency, TLS expiry), 3 alert rules; Grafana dashboard provisioned from files with Prometheus **and CloudWatch** data sources | `monitoring/` |
+| **Docs** | Runbook, troubleshooting, cost, architecture, security | `docs/` |
+
+## Architecture
+
+```mermaid
+flowchart LR
+ dev["Developer"] -->|pull request| gh["GitHub"]
+ gh --> ci["CI
tests, lint, image build + Trivy"]
+ gh --> tf["Terraform pipeline
fmt, validate, Checkov, plan
manual approval, apply"]
+ gh --> cd["CD
build, Trivy gate, push, deploy, smoke test"]
+ cd -->|push image| ecr[("ECR
immutable tags, scan on push")]
+ cd -->|SSM Run Command| ec2
+ tf -->|state + lock| s3[("S3 state bucket")]
+ user["User"] -->|HTTPS 443| ec2
+ subgraph aws["AWS us-east-1 - VPC 10.0.0.0/16 - public subnet"]
+ ec2["EC2 t3.micro - Amazon Linux 2023
Caddy :443 -> dpaste :8000
SQLite on a Docker volume"]
+ end
+ ec2 -->|pull, instance role| ecr
+ ec2 -->|SECRET_KEY| ssm[("SSM Parameter Store
SecureString")]
+ ec2 -->|container logs, metrics| cw[("CloudWatch
logs, alarms")]
+ mon["Prometheus + Grafana
(local)"] -->|blackbox HTTPS probe| ec2
+ mon -->|metrics| cw
+```
+
+Design choices, trade-offs and what a production version would change are in [`docs/architecture.md`](docs/architecture.md). Security controls are in [`docs/security.md`](docs/security.md).
+
+## How a change reaches production
+
+| Change | Path |
+|---|---|
+| Application / image / deploy script | PR -> CI (tests, lint, Trivy) -> merge -> **CD** builds, scans, pushes `cloudpulse:`, deploys via SSM, smoke-tests over HTTPS |
+| Infrastructure | PR -> CI + Terraform checks (fmt, validate, Checkov, plan in the run summary) -> merge -> plan -> **waits for my approval** -> apply |
+
+`master` is protected: pull request required, CI checks must pass, no force-push, enforced for admins.
+
+## Verified behaviour
+
+All of the following were executed and observed, not just configured:
+
+- **Automatic rollback:** deployed a deliberately broken image; the health check failed, `deploy.sh` rolled back to the previous release, and production kept serving HTTP 200.
+- **Data persistence:** snippets survive container replacement and automated deploys (SQLite on a named volume; startup logs show `No migrations to apply`).
+- **Approval gate:** the Terraform apply job paused until approved, then applied exactly the reviewed plan (1 add, 2 in-place changes); the local plan afterwards reported no changes.
+- **Alerting:** stopping the container fired `DpasteDown` in Prometheus and Grafana; it resolved after restart.
+- **Security gates:** Checkov passes with 21 checks, 0 failures, 11 documented exceptions; Trivy gates every image.
+
+## Key decisions and trade-offs
+
+- **OIDC for GitHub -> AWS was tested first** and is blocked in AWS Academy (`iam:CreateOpenIDConnectProvider` denied). CI/CD uses the lab's short-lived session credentials instead, refreshed each session with a script. In a real account: an OIDC role scoped to this repository.
+- **Single EC2 instance + SQLite**, which is what dpaste is designed for. Production would use an ALB, an Auto Scaling group and a managed database.
+- **Public subnet, no NAT gateway, no load balancer** to keep the cost near zero; production would place the instance in a private subnet.
+- **sslip.io hostname** because there is no domain; production would use Route 53 and an Elastic IP.
+- **cAdvisor was evaluated and removed**: it cannot identify containers with Docker Desktop's containerd image store, so container metrics come from CloudWatch instead.
+
+## Problems solved along the way
+
+Fifteen real issues are documented in [`docs/troubleshooting.md`](docs/troubleshooting.md). Examples: a 403 CSRF error that turned out to require HTTPS (dpaste sets secure cookies), `user_data` drift caused by Windows line endings, an AMI filter that silently selected the ECS-optimized image, and Checkov silently skipping files that contained an invalid byte.
-📖 Full documentation on [https://docs.dpaste.org](https://docs.dpaste.org)
+## Run it
+- **Locally:** `docker compose up --build` -> http://localhost:8000
+- **Monitoring stack:** see [`monitoring/docker-compose.yml`](monitoring/docker-compose.yml) (Grafana on :3000, Prometheus on :9090)
+- **On AWS, from zero:** [`docs/deployment-runbook.md`](docs/deployment-runbook.md)
+- **Cost:** [`docs/cost.md`](docs/cost.md)
-dpaste is a [pastebin](https://en.wikipedia.org/wiki/Pastebin) application written in [Python](https://www.python.org/) using the [Django](https://www.djangoproject.com/) framework. You can find a live installation on [dpaste.org.](https://dpaste.org)
+## How this was built
-The project is intended to run standalone as any regular Django Project, but it's also possible to install it into an existing project as a typical Django application.
+I built this project with an AI assistant (Claude) as a pair programmer. It proposed designs and drafted code, commands and parts of this documentation. I ran every command, reviewed every Terraform plan and pull request, worked through every failure, and made the final decisions. `docs/architecture.md` and `docs/security.md` are written by me.
+## Evidence
-The code is open source and available on Github: [https://github.com/darrenofficial/dpaste](https://github.com/darrenofficial/dpaste). If you found bugs, have problems or ideas with the project or the website installation, please create an *Issue* there.
+Screenshots of the running system are in [`docs/screenshots/`](docs/screenshots/).
-⚠️ dpaste requires at a minimum Python 3.9 and Django 3.2.
+| | |
+|---|---|
+| HTTPS site with valid certificate |  |
+| Grafana: probes + CloudWatch |  |
+| CD pipeline run |  |
+| Terraform apply waiting for approval |  |
+| Automatic rollback: logs of the container started by the rollback, same database |  |
+## License
-dpaste.org: https://dpaste.org/
-pastebin: https://en.wikipedia.org/wiki/Pastebin
+- dpaste application code: MIT License, (c) the dpaste authors (see [`LICENSE`](LICENSE)).
+- Infrastructure, pipelines, scripts and documentation added in this repository: MIT License, (c) Rayen Mabrouk.
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 071532b..c38a32c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,48 +1,18 @@
-version: "3.4"
-
services:
- staticfiles:
- build:
- context: .
- target: staticfiles
- args:
- BUILD_EXTRAS: dev
- image: staticfiles
- volumes:
- - ./package.json:/app/package.json
- - ./package-log.json:/app/package-log.json
- - ./client:/app/client:delegated
- - ./dpaste/static:/app/dpaste/static
-
app:
- stdin_open: true
- tty: true
- restart: always
build:
context: .
- target: build
- args:
- BUILD_EXTRAS: dev
- image: app
- environment:
- STATIC_ROOT: /collectstatic
- DATABASE_URL: sqlite:////db/dpaste.sqlite
- PORT: 8000
- volumes:
- - .:/app:delegated
- - data_collectstatic:/collectstatic
- - data_db:/db
+ dockerfile: Dockerfile.hardened
ports:
- "8000:8000"
- command: ./manage.py runserver 0:8000
-
- migration:
- image: app
- command: ./manage.py migrate --noinput
+ environment:
+ - DATABASE_URL=sqlite:////data/dpaste.sqlite
+ - SECRET_KEY=local-dev-secret-key-not-for-production
+ - DEBUG=True
+ - ALLOWED_HOSTS=*
volumes:
- - .:/app:delegated
- - data_db:/db
+ - dpaste_data:/data
+ restart: unless-stopped
volumes:
- data_db:
- data_collectstatic:
+ dpaste_data:
\ No newline at end of file
diff --git a/docs/cost.md b/docs/cost.md
new file mode 100644
index 0000000..9e06d75
--- /dev/null
+++ b/docs/cost.md
@@ -0,0 +1,46 @@
+# Cost analysis
+
+Last updated: 2026-09-23
+
+The project runs in an **AWS Academy Learner Lab** with a **$50 credit budget**. Academy stops the EC2 instance when a lab session ends, so the real spend is far below a 24/7 estimate.
+
+## Actual spend
+
+| Date | Credits used (Vocareum "Used $X of $50") |
+|---|---|
+| 2026-09-23 | _fill in from Vocareum_ |
+
+## What a 24/7 month would cost (estimate)
+
+Estimates use public **us-east-1 on-demand** prices at the time of writing; check the [AWS pricing pages](https://aws.amazon.com/pricing/) for current values.
+
+| Resource | Basis | ~ USD / month |
+|---|---|---|
+| EC2 t3.micro | $0.0104 / hour x 730 h | 7.59 |
+| Public IPv4 address | $0.005 / hour x 730 h | 3.65 |
+| EBS gp3, 20 GB | $0.08 / GB-month | 1.60 |
+| EC2 detailed monitoring | 7 metrics x $0.30 | 2.10 |
+| CloudWatch alarms | 2 x $0.10 | 0.20 |
+| CloudWatch Logs | < 1 GB ingested, 7-day retention | < 0.50 |
+| ECR storage | < 1 GB x $0.10 | < 0.10 |
+| S3 state bucket | a few KB, versioned | ~0 |
+| SSM Parameter Store | standard parameter | 0 |
+| Data transfer out | a pastebin demo, well under 1 GB | < 0.10 |
+| **Total** | | **~ $15-16** |
+
+GitHub Actions minutes are free for public repositories. Let's Encrypt certificates and sslip.io DNS are free.
+
+## Deliberate cost decisions
+
+| Not used | Typical monthly cost | Why skipped | Production alternative |
+|---|---|---|---|
+| Application Load Balancer | ~$16+ | single instance; Caddy terminates TLS | ALB + ACM certificate |
+| NAT gateway | ~$32+ | instance sits in a public subnet | private subnet + NAT or VPC endpoints |
+| RDS | ~$12+ (smallest) | dpaste is designed for SQLite | RDS PostgreSQL, stateless app tier |
+| Route 53 hosted zone + domain | ~$0.50 + domain | sslip.io hostname | Route 53 + Elastic IP |
+| EKS | ~$73 control plane | far beyond the need | EKS or ECS Fargate when there are several services |
+| Customer-managed KMS keys | $1 / key | AWS-managed encryption is already on | CMKs where key policy control is required |
+
+## Where the money would go first
+
+Detailed monitoring (~$2.10) was enabled on purpose: 1-minute metrics make the CPU alarm react faster. On a tight budget it is the first thing to turn off, followed by the public IPv4 charge (only avoidable with a load balancer or IPv6).
\ No newline at end of file
diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md
new file mode 100644
index 0000000..56de309
--- /dev/null
+++ b/docs/deployment-runbook.md
@@ -0,0 +1,251 @@
+# Deployment runbook
+
+Last updated: 2026-09-23
+
+How to operate CloudPulse on AWS, from an empty AWS Academy account to a running, monitored deployment.
+All commands are for **Windows PowerShell 5.1** (the environment this project was built on), run from the repository root unless stated otherwise.
+
+Each step is marked:
+- **[verified]**: executed and observed working during the build
+- **[not yet verified]**: written from the design, still to be tested end to end
+
+---
+
+## 0. Prerequisites
+
+| Tool | Version used | Install |
+|---|---|---|
+| Git | any recent | `winget install -e --id Git.Git` |
+| Docker Desktop | any recent | `winget install -e --id Docker.DockerDesktop` |
+| Terraform | 1.16.2 (**>= 1.10 required** for S3-native locking) | `winget install -e --id Hashicorp.Terraform` |
+| AWS CLI | v2 | `winget install -e --id Amazon.AWSCLI` |
+| GitHub CLI | any recent | `winget install -e --id GitHub.cli`, then `gh auth login` |
+
+AWS access: an **AWS Academy Learner Lab** (region `us-east-1`). The lab provides the `LabInstanceProfile` and the `vockey` key pair used by Terraform.
+
+---
+
+## 1. At the start of every lab session [verified]
+
+Academy credentials expire when the lab session ends (about 4 hours), and the EC2 instance is stopped between sessions.
+
+1. In Vocareum: **Start Lab**, wait for the green dot, open **AWS Details -> AWS CLI: Show**.
+2. Write the credentials **without a BOM** (Windows PowerShell 5's `Set-Content -Encoding UTF8` adds a BOM that the AWS SDK cannot parse):
+
+```powershell
+ $content = @"
+ [default]
+ aws_access_key_id=PASTE
+ aws_secret_access_key=PASTE
+ aws_session_token=PASTE
+ "@
+ [System.IO.File]::WriteAllText("$HOME\.aws\credentials", $content)
+```
+
+3. One-time only, the region:
+
+```powershell
+ [System.IO.File]::WriteAllText("$HOME\.aws\config", "[default]`nregion = us-east-1`noutput = json`n")
+```
+
+4. Verify, then push the new credentials to GitHub Actions:
+
+```powershell
+ aws sts get-caller-identity
+ powershell -ExecutionPolicy Bypass -File scripts\refresh-github-aws-secrets.ps1
+```
+
+5. If the AWS Console shows `explicit deny ... voc-cancel-cred`, the console tab is from an older session: close all console tabs and reopen the console from Vocareum.
+
+### After a lab restart: redeploy [not yet verified]
+
+When the instance starts again it gets a **new public IP**, so the sslip.io hostname changes. The containers restart automatically, but Caddy's certificate and Django's `ALLOWED_HOSTS` still refer to the old hostname. Redeploy so `deploy.sh` regenerates both:
+
+```powershell
+terraform -chdir=terraform apply -refresh-only -auto-approve # refresh outputs (new IP) in state
+terraform -chdir=terraform output app_url
+gh workflow run cd.yml --repo rayenmabrouk/cloudpulse # rebuild + redeploy through CD
+```
+
+If your home IP changed, update `terraform/terraform.tfvars` and the `TF_VAR_ALLOWED_SSH_CIDR` secret, then change the SSH rule through a pull request.
+
+---
+
+## 2. First-time setup from zero
+
+### 2.1 Remote state bucket [verified]
+
+The state bucket is created outside Terraform (Terraform cannot store its state in a bucket it has not created yet). The script is idempotent:
+
+```powershell
+powershell -ExecutionPolicy Bypass -File scripts\bootstrap-tfstate.ps1
+```
+
+It creates `cloudpulse-tfstate-` with versioning, SSE-S3 encryption, all public access blocked and a TLS-only bucket policy.
+
+### 2.2 Infrastructure [verified]
+
+```powershell
+Copy-Item terraform\terraform.tfvars.example terraform\terraform.tfvars
+# edit terraform.tfvars: allowed_ssh_cidr = "/32"
+cd terraform
+terraform init
+terraform plan -out=tfplan
+terraform apply tfplan
+terraform output
+cd ..
+```
+
+This creates the VPC, subnet, internet gateway, security groups, EC2 instance (Docker installed by `user_data`), ECR repository, SSM `SecureString` parameter with a generated Django `SECRET_KEY`, CloudWatch log group and alarms.
+
+After this first bootstrap, **infrastructure changes go through pull requests** and the Terraform pipeline (section 4.2).
+
+### 2.3 GitHub configuration [verified]
+
+```powershell
+# AWS credentials for CI/CD (repeat every lab session)
+powershell -ExecutionPolicy Bypass -File scripts\refresh-github-aws-secrets.ps1
+
+# SSH CIDR for the Terraform pipeline
+gh secret set TF_VAR_ALLOWED_SSH_CIDR --repo rayenmabrouk/cloudpulse --body "/32"
+```
+
+Environments (created with `gh api`, see the PR history for exact commands):
+- `production`: deployments allowed from `master` only (used by CD)
+- `infrastructure`: `master` only **and a required reviewer** (used by Terraform apply)
+
+Branch protection on `master`: pull request required, required checks `test`, `lint`, `docker-build-scan`, branch up to date, enforced for admins, no force-push or deletion.
+
+### 2.4 First deployment [verified]
+
+The first release was deployed manually to validate `deploy.sh`, then all later releases went through CD. To deploy the current `master` through CD:
+
+```powershell
+gh workflow run cd.yml --repo rayenmabrouk/cloudpulse
+```
+
+**[not yet verified]** The manual trigger (`workflow_dispatch`) runs the same jobs as a push but has not been exercised yet; every verified CD run was triggered by a merge.
+
+Get the URL:
+
+```powershell
+terraform -chdir=terraform output app_url
+```
+
+---
+
+## 3. What a deployment does (`scripts/deploy.sh`) [verified]
+
+Runs on the instance as root (sent by SSM Run Command from CD):
+
+1. Reads account ID and public IP from **IMDSv2** (session token required).
+2. Authenticates to ECR with the **ECR credential helper** via the instance role (falls back to `docker login` if the helper is unavailable).
+3. Pulls `cloudpulse:`.
+4. Reads `SECRET_KEY` from SSM Parameter Store and writes a root-only env file (`umask 077`) with `DEBUG=False` and `ALLOWED_HOSTS=,localhost,127.0.0.1`.
+5. Replaces the `dpaste` container on a private Docker network, published on `127.0.0.1:8000` only, SQLite on the `dpaste_data` volume, logs to CloudWatch.
+6. Health-checks `http://localhost:8000/` for up to 60 s. **On failure it restarts the previous image.**
+7. Starts or reloads **Caddy** (ports 80/443, automatic Let's Encrypt certificate, JSON access logs to CloudWatch).
+8. Installs the daily `cloudpulse-cleanup.timer`.
+
+---
+
+## 4. Day-to-day changes
+
+### 4.1 Application, image or deploy script [verified]
+
+```powershell
+git checkout -b feature/my-change
+# edit, commit
+git push -u origin feature/my-change
+gh pr create --repo rayenmabrouk/cloudpulse --base master --fill
+gh pr checks --repo rayenmabrouk/cloudpulse --watch
+gh pr merge --repo rayenmabrouk/cloudpulse --merge --delete-branch
+```
+
+The merge triggers CD when it touches `dpaste/`, `client/`, `Dockerfile.hardened`, `setup.*`, `package*.json`, `scripts/deploy.sh` or `.github/workflows/cd.yml`. Watch it:
+
+```powershell
+$RUN = gh run list --repo rayenmabrouk/cloudpulse --workflow cd.yml --limit 1 --json databaseId --jq ".[0].databaseId"
+gh run watch $RUN --repo rayenmabrouk/cloudpulse --exit-status
+```
+
+### 4.2 Infrastructure [verified]
+
+Same PR flow for changes under `terraform/`. On the PR, the Terraform workflow runs `fmt`, `validate`, Checkov and `plan` (the plan is in the run summary). After merge:
+
+1. Actions -> the Terraform run -> the **Apply (manual approval)** job waits.
+2. Review the plan in the run summary.
+3. **Review deployments -> infrastructure -> Approve and deploy.**
+4. Verify locally: `terraform -chdir=terraform plan` should report **No changes**.
+
+A Checkov finding must be either fixed or skipped inline with a justification (`# checkov:skip=:`).
+
+---
+
+## 5. Operations
+
+### Run a command on the server without SSH [verified]
+
+```powershell
+$ID = aws ec2 describe-instances --filters "Name=tag:Name,Values=cloudpulse-server" "Name=instance-state-name,Values=running" --query "Reservations[0].Instances[0].InstanceId" --output text
+[System.IO.File]::WriteAllText("$env:TEMP\cmd.json", '{"commands":["docker ps --format ''{{.Names}} {{.Image}} {{.Status}}''"]}')
+$CMD = aws ssm send-command --instance-ids $ID --document-name AWS-RunShellScript --parameters "file://$env:TEMP\cmd.json" --query Command.CommandId --output text
+Start-Sleep -Seconds 5
+aws ssm get-command-invocation --command-id $CMD --instance-id $ID --query StandardOutputContent --output text
+```
+
+The AWS CLI on Windows crashes when the output contains emoji (dpaste's startup banner). Append `| tr -cd '\11\12\40-\176'` to the remote command to strip them.
+
+### Logs [verified]
+
+```powershell
+aws logs tail /cloudpulse/dpaste --since 15m # app + Caddy
+aws logs tail /cloudpulse/dpaste --since 1h --filter-pattern "migrations"
+```
+
+### Manual rollback to a specific release [verified] (automatic rollback verified; manual uses the same script)
+
+List releases, then deploy an older tag through SSM:
+
+```powershell
+aws ecr describe-images --repository-name cloudpulse --query "sort_by(imageDetails,&imagePushedAt)[].imageTags[0]" --output text
+# then send-command with: /opt/cloudpulse/deploy.sh
+```
+
+### Snippet cleanup [verified]
+
+Runs daily via `cloudpulse-cleanup.timer`. Run it now: `systemctl start cloudpulse-cleanup.service` (through SSM), then `journalctl -u cloudpulse-cleanup.service -n 5`.
+
+### SSH (break-glass) [verified]
+
+Only from the IP in `allowed_ssh_cidr`, with the Academy key (`labsuser.pem`, permissions restricted with `icacls`):
+
+```powershell
+ssh -i "$HOME\.ssh\labsuser.pem" "ec2-user@$(terraform -chdir=terraform output -raw instance_public_ip)"
+```
+
+---
+
+## 6. Monitoring stack (local) [verified]
+
+```powershell
+cd monitoring
+powershell -ExecutionPolicy Bypass -File set-production-target.ps1
+docker compose up -d --build
+cd ..
+```
+
+- Grafana: http://localhost:3000 (admin / `GRAFANA_ADMIN_PASSWORD`, default `cloudpulse-local`) -> Dashboards -> CloudPulse
+- Prometheus: http://localhost:9090 (targets, alerts)
+- The CloudWatch panels use `~/.aws` (read-only mount) and stop working when the lab session credentials expire.
+
+---
+
+## 7. Teardown [not yet verified]
+
+```powershell
+terraform -chdir=terraform plan -destroy -out=destroy.tfplan
+terraform -chdir=terraform apply destroy.tfplan
+```
+
+`force_delete = true` on the ECR repository removes its images. The state bucket is not managed by Terraform: empty all object **versions** and delete it manually afterwards, only once the state is no longer needed.
\ No newline at end of file
diff --git a/docs/screenshots/01-github-fork-dpaste.png b/docs/screenshots/01-github-fork-dpaste.png
new file mode 100644
index 0000000..bb44984
Binary files /dev/null and b/docs/screenshots/01-github-fork-dpaste.png differ
diff --git a/docs/screenshots/02-docker-build-hardened.png b/docs/screenshots/02-docker-build-hardened.png
new file mode 100644
index 0000000..b7ecd29
Binary files /dev/null and b/docs/screenshots/02-docker-build-hardened.png differ
diff --git a/docs/screenshots/03-dpaste-local-docker.png b/docs/screenshots/03-dpaste-local-docker.png
new file mode 100644
index 0000000..de7ce34
Binary files /dev/null and b/docs/screenshots/03-dpaste-local-docker.png differ
diff --git a/docs/screenshots/04-api-security-headers.png b/docs/screenshots/04-api-security-headers.png
new file mode 100644
index 0000000..fbbcbbb
Binary files /dev/null and b/docs/screenshots/04-api-security-headers.png differ
diff --git a/docs/screenshots/05-ci-first-runs-failing.png b/docs/screenshots/05-ci-first-runs-failing.png
new file mode 100644
index 0000000..be2a53b
Binary files /dev/null and b/docs/screenshots/05-ci-first-runs-failing.png differ
diff --git a/docs/screenshots/06-ci-trivy-failure-annotations.png b/docs/screenshots/06-ci-trivy-failure-annotations.png
new file mode 100644
index 0000000..95cfc9e
Binary files /dev/null and b/docs/screenshots/06-ci-trivy-failure-annotations.png differ
diff --git a/docs/screenshots/07-ci-green-after-fix.png b/docs/screenshots/07-ci-green-after-fix.png
new file mode 100644
index 0000000..b2d8c97
Binary files /dev/null and b/docs/screenshots/07-ci-green-after-fix.png differ
diff --git a/docs/screenshots/08-ci-run-success-3-jobs.png b/docs/screenshots/08-ci-run-success-3-jobs.png
new file mode 100644
index 0000000..546f182
Binary files /dev/null and b/docs/screenshots/08-ci-run-success-3-jobs.png differ
diff --git a/docs/screenshots/10-academy-learner-lab.png b/docs/screenshots/10-academy-learner-lab.png
new file mode 100644
index 0000000..dbe91a6
Binary files /dev/null and b/docs/screenshots/10-academy-learner-lab.png differ
diff --git a/docs/screenshots/11-academy-console-home.png b/docs/screenshots/11-academy-console-home.png
new file mode 100644
index 0000000..b5db141
Binary files /dev/null and b/docs/screenshots/11-academy-console-home.png differ
diff --git a/docs/screenshots/12-academy-ec2-dashboard.png b/docs/screenshots/12-academy-ec2-dashboard.png
new file mode 100644
index 0000000..e7510cb
Binary files /dev/null and b/docs/screenshots/12-academy-ec2-dashboard.png differ
diff --git a/docs/screenshots/13-academy-ecr.png b/docs/screenshots/13-academy-ecr.png
new file mode 100644
index 0000000..bbabfb8
Binary files /dev/null and b/docs/screenshots/13-academy-ecr.png differ
diff --git a/docs/screenshots/14-academy-vpc.png b/docs/screenshots/14-academy-vpc.png
new file mode 100644
index 0000000..80ee200
Binary files /dev/null and b/docs/screenshots/14-academy-vpc.png differ
diff --git a/docs/screenshots/15-academy-s3.png b/docs/screenshots/15-academy-s3.png
new file mode 100644
index 0000000..6a13983
Binary files /dev/null and b/docs/screenshots/15-academy-s3.png differ
diff --git a/docs/screenshots/16-academy-systems-manager.png b/docs/screenshots/16-academy-systems-manager.png
new file mode 100644
index 0000000..1312601
Binary files /dev/null and b/docs/screenshots/16-academy-systems-manager.png differ
diff --git a/docs/screenshots/17-academy-parameter-store.png b/docs/screenshots/17-academy-parameter-store.png
new file mode 100644
index 0000000..46a3728
Binary files /dev/null and b/docs/screenshots/17-academy-parameter-store.png differ
diff --git a/docs/screenshots/18-academy-cloudwatch.png b/docs/screenshots/18-academy-cloudwatch.png
new file mode 100644
index 0000000..a13fdec
Binary files /dev/null and b/docs/screenshots/18-academy-cloudwatch.png differ
diff --git a/docs/screenshots/19-academy-iam.png b/docs/screenshots/19-academy-iam.png
new file mode 100644
index 0000000..10099a6
Binary files /dev/null and b/docs/screenshots/19-academy-iam.png differ
diff --git a/docs/screenshots/20-academy-labrole.png b/docs/screenshots/20-academy-labrole.png
new file mode 100644
index 0000000..ad95944
Binary files /dev/null and b/docs/screenshots/20-academy-labrole.png differ
diff --git a/docs/screenshots/21-academy-labrole-policies.png b/docs/screenshots/21-academy-labrole-policies.png
new file mode 100644
index 0000000..e201269
Binary files /dev/null and b/docs/screenshots/21-academy-labrole-policies.png differ
diff --git a/docs/screenshots/30-terraform-init.png b/docs/screenshots/30-terraform-init.png
new file mode 100644
index 0000000..6c87436
Binary files /dev/null and b/docs/screenshots/30-terraform-init.png differ
diff --git a/docs/screenshots/31-terraform-apply-complete.png b/docs/screenshots/31-terraform-apply-complete.png
new file mode 100644
index 0000000..deb7a11
Binary files /dev/null and b/docs/screenshots/31-terraform-apply-complete.png differ
diff --git a/docs/screenshots/32-console-voc-cancel-cred-denied.png b/docs/screenshots/32-console-voc-cancel-cred-denied.png
new file mode 100644
index 0000000..68a3f42
Binary files /dev/null and b/docs/screenshots/32-console-voc-cancel-cred-denied.png differ
diff --git a/docs/screenshots/33-ec2-instance-running.png b/docs/screenshots/33-ec2-instance-running.png
new file mode 100644
index 0000000..8759cf4
Binary files /dev/null and b/docs/screenshots/33-ec2-instance-running.png differ
diff --git a/docs/screenshots/34-ecr-repo-created.png b/docs/screenshots/34-ecr-repo-created.png
new file mode 100644
index 0000000..4a22bea
Binary files /dev/null and b/docs/screenshots/34-ecr-repo-created.png differ
diff --git a/docs/screenshots/35-vpc-created.png b/docs/screenshots/35-vpc-created.png
new file mode 100644
index 0000000..4d35821
Binary files /dev/null and b/docs/screenshots/35-vpc-created.png differ
diff --git a/docs/screenshots/36-ec2-bootstrap-verified.png b/docs/screenshots/36-ec2-bootstrap-verified.png
new file mode 100644
index 0000000..f0edbc3
Binary files /dev/null and b/docs/screenshots/36-ec2-bootstrap-verified.png differ
diff --git a/docs/screenshots/37-pr-base-upstream-trap.png b/docs/screenshots/37-pr-base-upstream-trap.png
new file mode 100644
index 0000000..bda00ac
Binary files /dev/null and b/docs/screenshots/37-pr-base-upstream-trap.png differ
diff --git a/docs/screenshots/38-pr1-terraform.png b/docs/screenshots/38-pr1-terraform.png
new file mode 100644
index 0000000..3a1c444
Binary files /dev/null and b/docs/screenshots/38-pr1-terraform.png differ
diff --git a/docs/screenshots/39-pr1-checks-running.png b/docs/screenshots/39-pr1-checks-running.png
new file mode 100644
index 0000000..00e3c66
Binary files /dev/null and b/docs/screenshots/39-pr1-checks-running.png differ
diff --git a/docs/screenshots/40-local-prod-mode.png b/docs/screenshots/40-local-prod-mode.png
new file mode 100644
index 0000000..06a8a20
Binary files /dev/null and b/docs/screenshots/40-local-prod-mode.png differ
diff --git a/docs/screenshots/41-local-prod-snippet.png b/docs/screenshots/41-local-prod-snippet.png
new file mode 100644
index 0000000..b9e6aa2
Binary files /dev/null and b/docs/screenshots/41-local-prod-snippet.png differ
diff --git a/docs/screenshots/42-deploy-script-healthy.png b/docs/screenshots/42-deploy-script-healthy.png
new file mode 100644
index 0000000..c1d4470
Binary files /dev/null and b/docs/screenshots/42-deploy-script-healthy.png differ
diff --git a/docs/screenshots/43-cloudwatch-logs-cli.png b/docs/screenshots/43-cloudwatch-logs-cli.png
new file mode 100644
index 0000000..dc20d04
Binary files /dev/null and b/docs/screenshots/43-cloudwatch-logs-cli.png differ
diff --git a/docs/screenshots/44-csrf-403-over-http.png b/docs/screenshots/44-csrf-403-over-http.png
new file mode 100644
index 0000000..50b2a86
Binary files /dev/null and b/docs/screenshots/44-csrf-403-over-http.png differ
diff --git a/docs/screenshots/45-https-snippet-created.png b/docs/screenshots/45-https-snippet-created.png
new file mode 100644
index 0000000..04e246e
Binary files /dev/null and b/docs/screenshots/45-https-snippet-created.png differ
diff --git a/docs/screenshots/50-github-environment-production.png b/docs/screenshots/50-github-environment-production.png
new file mode 100644
index 0000000..7257e95
Binary files /dev/null and b/docs/screenshots/50-github-environment-production.png differ
diff --git a/docs/screenshots/51-github-actions-secrets.png b/docs/screenshots/51-github-actions-secrets.png
new file mode 100644
index 0000000..9818ba1
Binary files /dev/null and b/docs/screenshots/51-github-actions-secrets.png differ
diff --git a/docs/screenshots/52-cd-run-success.png b/docs/screenshots/52-cd-run-success.png
new file mode 100644
index 0000000..7b34c29
Binary files /dev/null and b/docs/screenshots/52-cd-run-success.png differ
diff --git a/docs/screenshots/53-cd-pr-checks.png b/docs/screenshots/53-cd-pr-checks.png
new file mode 100644
index 0000000..430c597
Binary files /dev/null and b/docs/screenshots/53-cd-pr-checks.png differ
diff --git a/docs/screenshots/54-tf-pr-checks.png b/docs/screenshots/54-tf-pr-checks.png
new file mode 100644
index 0000000..b2d666c
Binary files /dev/null and b/docs/screenshots/54-tf-pr-checks.png differ
diff --git a/docs/screenshots/55-tf-apply-waiting-approval.png b/docs/screenshots/55-tf-apply-waiting-approval.png
new file mode 100644
index 0000000..e3aa8ae
Binary files /dev/null and b/docs/screenshots/55-tf-apply-waiting-approval.png differ
diff --git a/docs/screenshots/60-monitoring-stack-up.png b/docs/screenshots/60-monitoring-stack-up.png
new file mode 100644
index 0000000..e200caf
Binary files /dev/null and b/docs/screenshots/60-monitoring-stack-up.png differ
diff --git a/docs/screenshots/61-prometheus-targets.png b/docs/screenshots/61-prometheus-targets.png
new file mode 100644
index 0000000..417efdc
Binary files /dev/null and b/docs/screenshots/61-prometheus-targets.png differ
diff --git a/docs/screenshots/62-prometheus-alerts.png b/docs/screenshots/62-prometheus-alerts.png
new file mode 100644
index 0000000..ff191f4
Binary files /dev/null and b/docs/screenshots/62-prometheus-alerts.png differ
diff --git a/docs/screenshots/63-grafana-dashboard.png b/docs/screenshots/63-grafana-dashboard.png
new file mode 100644
index 0000000..e0dbb13
Binary files /dev/null and b/docs/screenshots/63-grafana-dashboard.png differ
diff --git a/docs/screenshots/64-grafana-alert-firing.png b/docs/screenshots/64-grafana-alert-firing.png
new file mode 100644
index 0000000..5621e68
Binary files /dev/null and b/docs/screenshots/64-grafana-alert-firing.png differ
diff --git a/docs/screenshots/65-grafana-dashboard-final.png b/docs/screenshots/65-grafana-dashboard-final.png
new file mode 100644
index 0000000..130bee0
Binary files /dev/null and b/docs/screenshots/65-grafana-dashboard-final.png differ
diff --git a/docs/screenshots/70-final-ec2-instance.png b/docs/screenshots/70-final-ec2-instance.png
new file mode 100644
index 0000000..ff8d589
Binary files /dev/null and b/docs/screenshots/70-final-ec2-instance.png differ
diff --git a/docs/screenshots/71-final-security-group.png b/docs/screenshots/71-final-security-group.png
new file mode 100644
index 0000000..c6c869c
Binary files /dev/null and b/docs/screenshots/71-final-security-group.png differ
diff --git a/docs/screenshots/72-final-ecr-images.png b/docs/screenshots/72-final-ecr-images.png
new file mode 100644
index 0000000..c6beff5
Binary files /dev/null and b/docs/screenshots/72-final-ecr-images.png differ
diff --git a/docs/screenshots/73-final-ssm-parameter.png b/docs/screenshots/73-final-ssm-parameter.png
new file mode 100644
index 0000000..423bab5
Binary files /dev/null and b/docs/screenshots/73-final-ssm-parameter.png differ
diff --git a/docs/screenshots/74-final-s3-state-versions.png b/docs/screenshots/74-final-s3-state-versions.png
new file mode 100644
index 0000000..67baf1c
Binary files /dev/null and b/docs/screenshots/74-final-s3-state-versions.png differ
diff --git a/docs/screenshots/75-final-cloudwatch-overview.png b/docs/screenshots/75-final-cloudwatch-overview.png
new file mode 100644
index 0000000..190dcdc
Binary files /dev/null and b/docs/screenshots/75-final-cloudwatch-overview.png differ
diff --git a/docs/screenshots/76-final-cloudwatch-alarms.png b/docs/screenshots/76-final-cloudwatch-alarms.png
new file mode 100644
index 0000000..a80ff7a
Binary files /dev/null and b/docs/screenshots/76-final-cloudwatch-alarms.png differ
diff --git a/docs/screenshots/77-final-site-https.png b/docs/screenshots/77-final-site-https.png
new file mode 100644
index 0000000..cd4bbe0
Binary files /dev/null and b/docs/screenshots/77-final-site-https.png differ
diff --git a/docs/screenshots/78-final-cloudwatch-log-streams.png b/docs/screenshots/78-final-cloudwatch-log-streams.png
new file mode 100644
index 0000000..bbeeb5c
Binary files /dev/null and b/docs/screenshots/78-final-cloudwatch-log-streams.png differ
diff --git a/docs/screenshots/79-rollback-container-logs.png b/docs/screenshots/79-rollback-container-logs.png
new file mode 100644
index 0000000..448bebd
Binary files /dev/null and b/docs/screenshots/79-rollback-container-logs.png differ
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..dcd118b
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,81 @@
+# Troubleshooting
+
+Last updated: 2026-09-23
+
+Every problem below actually happened while building CloudPulse. Each entry gives the symptom, the root cause and the fix.
+
+## Windows / PowerShell
+
+### 1. `terraform init`: "Invalid character encoding"
+- **Symptom:** `Invalid character encoding` / `Unterminated template string` in `outputs.tf`.
+- **Cause:** an em-dash in a description was written by PowerShell in a legacy encoding, and `` inside a string looked like a template.
+- **Fix:** keep `.tf` files ASCII-only.
+
+### 2. Terraform / AWS CLI: "No valid credential sources found"
+- **Symptom:** credentials file present but not read.
+- **Cause:** `Set-Content -Encoding UTF8` in Windows PowerShell 5 writes a **byte order mark**; the AWS SDK cannot parse the first line.
+- **Fix:** `[System.IO.File]::WriteAllText($path, $content)` (UTF-8 without BOM).
+
+### 3. `docker login` to ECR: `400 Bad Request`
+- **Cause:** piping `aws ecr get-login-password` into `docker login` in PowerShell 5 re-encodes the token.
+- **Fix:** run the pipe in cmd: `cmd /c "aws ecr get-login-password | docker login --username AWS --password-stdin "`.
+
+### 4. AWS CLI: `'charmap' codec can't encode character`
+- **Cause:** remote output contained an emoji (dpaste's startup banner) and the CLI prints with the Windows console code page; `PYTHONUTF8` is ignored by the bundled Python.
+- **Fix:** strip non-ASCII on the server: `... | tr -cd '\11\12\40-\176'`.
+
+### 5. `terraform plan` wants to modify `user_data` although nothing changed
+- **Symptom:** in-place update of the instance (which would restart it) after switching Git branches.
+- **Cause:** `user_data.sh` switched between CRLF and LF line endings; Terraform hashes the bytes.
+- **Fix:** `.gitattributes` with `*.sh text eol=lf` (and `*.tf`, `*.hcl`), then a one-time apply. Lesson learned: the repository already had a `.gitattributes`; **append** to existing config files instead of overwriting them.
+
+### 6. AWS CLI: "You must specify a region"
+- **Fix:** create `~/.aws/config` with `region = us-east-1`.
+
+## AWS Academy
+
+### 7. Console: `explicit deny ... policy/voc-cancel-cred`
+- **Cause:** a console tab opened in a previous lab session; Vocareum revokes older sessions.
+- **Fix:** close all console tabs and reopen the console from the Vocareum AWS link.
+
+### 8. OIDC for GitHub Actions: `AccessDenied` on `iam:CreateOpenIDConnectProvider`
+- **Cause:** the Learner Lab does not allow creating IAM identity providers or roles.
+- **Fix / trade-off:** use the lab's short-lived session credentials as GitHub secrets, refreshed by `scripts/refresh-github-aws-secrets.ps1` each session.
+
+## Terraform
+
+### 9. `InvalidBlockDeviceMapping: Volume of size 20GB is smaller than snapshot ... expect size >= 30GB`
+- **Cause:** the AMI filter `al2023-ami-*-x86_64` also matched `al2023-ami-ecs-hvm-...` (ECS-optimized, 30 GB snapshot) and `most_recent` picked it. The login banner said "Amazon Linux 2023 (ECS Optimized)".
+- **Fix:** filter `al2023-ami-2023.*-x86_64`. The replacement plan also updated both CloudWatch alarms (their `InstanceId` dimension), which is why plans are reviewed.
+
+### 10. Checkov passes but ignores some files
+- **Symptom:** `Parsing errors: 3` and CloudWatch resources never scanned.
+- **Cause:** three files contained byte `0x97` (a Windows-1252 em-dash) in comments. Terraform tolerated it; Checkov could not parse the files and skipped them.
+- **Fix:** convert every `.tf` file to ASCII.
+
+## Application / deployment
+
+### 11. `403 Forbidden - CSRF verification failed` when creating a snippet
+- **Cause:** dpaste sets `CSRF_COOKIE_SECURE = True` and `SESSION_COOKIE_SECURE = True`. Over plain HTTP the browser drops these cookies. It worked locally because browsers treat `localhost` as secure.
+- **Fix:** serve over HTTPS (Caddy + Let's Encrypt on an sslip.io hostname). dpaste already sets `SECURE_PROXY_SSL_HEADER`, so it trusts `X-Forwarded-Proto` from the proxy. Disabling the secure cookies was rejected.
+
+### 12. Snippets would have been lost on every redeploy
+- **Cause:** the deploy script mounted the volume at `/db`, but the image's `DATABASE_URL` is `sqlite:////data/dpaste.sqlite`.
+- **Fix:** read the path from the image (`docker image inspect --format '{{json .Config.Env}}'`) and mount at `/data`. Caught before the first deploy.
+
+### 13. ECR scan status `None`, three entries for one image
+- **Cause:** Docker Buildx adds a provenance attestation, so the push is an image index, which ECR basic scanning does not handle.
+- **Fix:** build with `--provenance=false --sbom=false`.
+
+### 14. `docker login` warning: password stored unencrypted in `/root/.docker/config.json`
+- **Fix:** Amazon ECR credential helper; `config.json` now only contains `credHelpers`.
+
+## Monitoring
+
+### 15. cAdvisor running but container panels empty
+- **Symptom:** only one anonymous series; logs show `failed to identify the read-write layer ID ... layerdb/mounts/...: no such file or directory`.
+- **Cause:** Docker Desktop uses the containerd image store; cAdvisor v0.49 expects the classic `overlay2` layout.
+- **Fix:** removed cAdvisor; the Grafana dashboard shows production EC2 CPU and network from CloudWatch instead.
+
+### Grafana: "Invalid username or password"
+- **Fix:** `docker exec cloudpulse-grafana grafana cli admin reset-admin-password `. Repeated failures trigger a short lockout.
\ No newline at end of file
diff --git a/docs/upstream-dpaste-README.md b/docs/upstream-dpaste-README.md
new file mode 100644
index 0000000..ee7f98b
--- /dev/null
+++ b/docs/upstream-dpaste-README.md
@@ -0,0 +1,24 @@
+Dpaste
+---
+
+[](https://github.com/DarrenOfficial/dpaste/actions/workflows/python.yml)
+[](https://hub.docker.com/r/darrenofficial/dpaste)
+
+
+----
+
+📖 Full documentation on [https://docs.dpaste.org](https://docs.dpaste.org)
+
+
+dpaste is a [pastebin](https://en.wikipedia.org/wiki/Pastebin) application written in [Python](https://www.python.org/) using the [Django](https://www.djangoproject.com/) framework. You can find a live installation on [dpaste.org.](https://dpaste.org)
+
+The project is intended to run standalone as any regular Django Project, but it's also possible to install it into an existing project as a typical Django application.
+
+
+The code is open source and available on Github: [https://github.com/darrenofficial/dpaste](https://github.com/darrenofficial/dpaste). If you found bugs, have problems or ideas with the project or the website installation, please create an *Issue* there.
+
+⚠️ dpaste requires at a minimum Python 3.9 and Django 3.2.
+
+
+dpaste.org: https://dpaste.org/
+pastebin: https://en.wikipedia.org/wiki/Pastebin
diff --git a/monitoring/blackbox/blackbox.yml b/monitoring/blackbox/blackbox.yml
new file mode 100644
index 0000000..ec077e0
--- /dev/null
+++ b/monitoring/blackbox/blackbox.yml
@@ -0,0 +1,9 @@
+modules:
+ http_2xx:
+ prober: http
+ timeout: 10s
+ http:
+ method: GET
+ valid_status_codes: [200]
+ follow_redirects: true
+ preferred_ip_protocol: ip4
\ No newline at end of file
diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml
new file mode 100644
index 0000000..dcd3a7a
--- /dev/null
+++ b/monitoring/docker-compose.yml
@@ -0,0 +1,71 @@
+# CloudPulse - local observability stack
+# Probes the local container AND the live AWS deployment (black-box monitoring:
+# no changes to the dpaste application code).
+# Usage (from monitoring/):
+# powershell -ExecutionPolicy Bypass -File set-production-target.ps1
+# docker compose up -d --build
+# Grafana: http://localhost:3000 Prometheus: http://localhost:9090
+name: cloudpulse-monitoring
+
+services:
+ dpaste:
+ build:
+ context: ..
+ dockerfile: Dockerfile.hardened
+ image: cloudpulse:local
+ container_name: cloudpulse-dpaste
+ environment:
+ SECRET_KEY: local-monitoring-demo-not-a-secret
+ DEBUG: "False"
+ ALLOWED_HOSTS: localhost,127.0.0.1,dpaste
+ ports:
+ - "127.0.0.1:8000:8000"
+ volumes:
+ - dpaste_data:/data
+ restart: unless-stopped
+
+ blackbox:
+ image: prom/blackbox-exporter:v0.25.0
+ container_name: cloudpulse-blackbox
+ command: ["--config.file=/etc/blackbox/blackbox.yml"]
+ volumes:
+ - ./blackbox/blackbox.yml:/etc/blackbox/blackbox.yml:ro
+ restart: unless-stopped
+
+
+ prometheus:
+ image: prom/prometheus:v3.2.1
+ container_name: cloudpulse-prometheus
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.retention.time=7d
+ volumes:
+ - ./prometheus:/etc/prometheus:ro
+ - prometheus_data:/prometheus
+ ports:
+ - "127.0.0.1:9090:9090"
+ restart: unless-stopped
+
+ grafana:
+ image: grafana/grafana:11.6.0
+ container_name: cloudpulse-grafana
+ environment:
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-cloudpulse-local}
+ GF_USERS_ALLOW_SIGN_UP: "false"
+ volumes:
+ - ./grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
+ - grafana_data:/var/lib/grafana
+ # AWS Academy session credentials, read-only, for the CloudWatch datasource
+ - ${USERPROFILE}/.aws:/usr/share/grafana/.aws:ro
+ ports:
+ - "127.0.0.1:3000:3000"
+ depends_on:
+ - prometheus
+ restart: unless-stopped
+
+volumes:
+ dpaste_data:
+ prometheus_data:
+ grafana_data:
\ No newline at end of file
diff --git a/monitoring/grafana/dashboards/cloudpulse-overview.json b/monitoring/grafana/dashboards/cloudpulse-overview.json
new file mode 100644
index 0000000..9b82f3c
--- /dev/null
+++ b/monitoring/grafana/dashboards/cloudpulse-overview.json
@@ -0,0 +1,111 @@
+{
+ "uid": "cloudpulse-overview",
+ "title": "CloudPulse - dpaste overview",
+ "tags": ["cloudpulse"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "30s",
+ "time": { "from": "now-1h", "to": "now" },
+ "panels": [
+ {
+ "id": 1, "type": "stat", "title": "Production",
+ "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "probe_success{env=\"production\"}" }],
+ "fieldConfig": { "defaults": {
+ "mappings": [{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }],
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
+ }, "overrides": [] },
+ "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 2, "type": "stat", "title": "Local",
+ "gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "probe_success{env=\"local\"}" }],
+ "fieldConfig": { "defaults": {
+ "mappings": [{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }],
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
+ }, "overrides": [] },
+ "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 3, "type": "stat", "title": "Production HTTP status",
+ "gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "probe_http_status_code{env=\"production\"}" }],
+ "fieldConfig": { "defaults": {
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 200 }, { "color": "red", "value": 300 }] }
+ }, "overrides": [] },
+ "options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 4, "type": "stat", "title": "TLS certificate expires in",
+ "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "(probe_ssl_earliest_cert_expiry{env=\"production\"} - time()) / 86400" }],
+ "fieldConfig": { "defaults": {
+ "unit": "d", "decimals": 1,
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "orange", "value": 14 }, { "color": "green", "value": 30 }] }
+ }, "overrides": [] },
+ "options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 5, "type": "stat", "title": "Firing alerts",
+ "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "count(ALERTS{alertstate=\"firing\"}) or vector(0)" }],
+ "fieldConfig": { "defaults": {
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
+ }, "overrides": [] },
+ "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 6, "type": "timeseries", "title": "Response time (full request)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "probe_duration_seconds", "legendFormat": "{{env}}" }],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 7, "type": "timeseries", "title": "Production request phases (DNS, connect, TLS, processing, transfer)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "refId": "A", "expr": "probe_http_duration_seconds{env=\"production\"}", "legendFormat": "{{phase}}" }],
+ "fieldConfig": { "defaults": { "unit": "s", "custom": { "stacking": { "mode": "normal" }, "fillOpacity": 30 } }, "overrides": [] }
+ },
+ {
+ "id": 8, "type": "timeseries", "title": "Production EC2 CPU (CloudWatch, 1-minute detailed monitoring)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
+ "datasource": { "type": "cloudwatch", "uid": "cloudwatch" },
+ "targets": [{
+ "refId": "A", "datasource": { "type": "cloudwatch", "uid": "cloudwatch" },
+ "queryMode": "Metrics", "region": "default", "namespace": "AWS/EC2", "metricName": "CPUUtilization",
+ "dimensions": { "InstanceId": "*" }, "matchExact": false, "statistic": "Average", "period": "60",
+ "metricQueryType": 0, "metricEditorMode": 0, "id": "", "expression": ""
+ }],
+ "fieldConfig": { "defaults": { "unit": "percent", "min": 0 }, "overrides": [] }
+ },
+ {
+ "id": 9, "type": "timeseries", "title": "Production EC2 network (CloudWatch)",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
+ "datasource": { "type": "cloudwatch", "uid": "cloudwatch" },
+ "targets": [
+ {
+ "refId": "A", "datasource": { "type": "cloudwatch", "uid": "cloudwatch" },
+ "queryMode": "Metrics", "region": "default", "namespace": "AWS/EC2", "metricName": "NetworkIn",
+ "dimensions": { "InstanceId": "*" }, "matchExact": false, "statistic": "Sum", "period": "60",
+ "metricQueryType": 0, "metricEditorMode": 0, "id": "", "expression": ""
+ },
+ {
+ "refId": "B", "datasource": { "type": "cloudwatch", "uid": "cloudwatch" },
+ "queryMode": "Metrics", "region": "default", "namespace": "AWS/EC2", "metricName": "NetworkOut",
+ "dimensions": { "InstanceId": "*" }, "matchExact": false, "statistic": "Sum", "period": "60",
+ "metricQueryType": 0, "metricEditorMode": 0, "id": "", "expression": ""
+ }
+ ],
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/monitoring/grafana/provisioning/dashboards/cloudpulse.yml b/monitoring/grafana/provisioning/dashboards/cloudpulse.yml
new file mode 100644
index 0000000..3113b4b
--- /dev/null
+++ b/monitoring/grafana/provisioning/dashboards/cloudpulse.yml
@@ -0,0 +1,8 @@
+apiVersion: 1
+providers:
+ - name: CloudPulse
+ folder: CloudPulse
+ type: file
+ disableDeletion: true
+ options:
+ path: /var/lib/grafana/dashboards
\ No newline at end of file
diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml
new file mode 100644
index 0000000..13b4ee8
--- /dev/null
+++ b/monitoring/grafana/provisioning/datasources/prometheus.yml
@@ -0,0 +1,16 @@
+apiVersion: 1
+datasources:
+ - name: Prometheus
+ uid: prometheus
+ type: prometheus
+ access: proxy
+ url: http://prometheus:9090
+ isDefault: true
+
+ # Uses the AWS SDK default credential chain (~/.aws mounted read-only)
+ - name: CloudWatch
+ uid: cloudwatch
+ type: cloudwatch
+ jsonData:
+ authType: default
+ defaultRegion: us-east-1
\ No newline at end of file
diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml
new file mode 100644
index 0000000..81b99b4
--- /dev/null
+++ b/monitoring/prometheus/alerts.yml
@@ -0,0 +1,29 @@
+groups:
+ - name: cloudpulse
+ rules:
+ - alert: DpasteDown
+ expr: probe_success == 0
+ for: 1m
+ labels:
+ severity: critical
+ annotations:
+ summary: "dpaste is down ({{ $labels.env }})"
+ description: "HTTP probe of {{ $labels.instance }} has been failing for more than 1 minute."
+
+ - alert: DpasteSlowResponse
+ expr: probe_duration_seconds > 2
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "dpaste is slow ({{ $labels.env }})"
+ description: "Probe of {{ $labels.instance }} takes more than 2s for 5 minutes."
+
+ - alert: TLSCertificateExpiringSoon
+ expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 14
+ for: 10m
+ labels:
+ severity: warning
+ annotations:
+ summary: "TLS certificate expires in less than 14 days"
+ description: "Certificate for {{ $labels.instance }} expires soon; Caddy should have renewed it."
\ No newline at end of file
diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml
new file mode 100644
index 0000000..299e592
--- /dev/null
+++ b/monitoring/prometheus/prometheus.yml
@@ -0,0 +1,35 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+rule_files:
+ - /etc/prometheus/alerts.yml
+
+scrape_configs:
+ - job_name: prometheus
+ static_configs:
+ - targets: ["localhost:9090"]
+
+ # Black-box HTTP probes: local container (static) + production (file-based,
+ # written by set-production-target.ps1 because the sslip.io hostname changes with the IP)
+ - job_name: blackbox-http
+ metrics_path: /probe
+ params:
+ module: [http_2xx]
+ static_configs:
+ - targets: ["http://dpaste:8000/"]
+ labels:
+ env: local
+ file_sd_configs:
+ - files: ["/etc/prometheus/targets.d/*.yml"]
+ relabel_configs:
+ - source_labels: [__address__]
+ target_label: __param_target
+ - source_labels: [__param_target]
+ target_label: instance
+ - target_label: __address__
+ replacement: blackbox:9115
+
+ - job_name: blackbox-exporter
+ static_configs:
+ - targets: ["blackbox:9115"]
diff --git a/monitoring/prometheus/targets.d/.gitkeep b/monitoring/prometheus/targets.d/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/monitoring/set-production-target.ps1 b/monitoring/set-production-target.ps1
new file mode 100644
index 0000000..b617108
--- /dev/null
+++ b/monitoring/set-production-target.ps1
@@ -0,0 +1,9 @@
+# Point the black-box probe at the current production URL.
+# The sslip.io hostname is derived from the EC2 public IP, which changes when the
+# AWS Academy session restarts, so the target is generated from Terraform output.
+$url = (terraform -chdir="$PSScriptRoot\..\terraform" output -raw app_url)
+if ($LASTEXITCODE -ne 0 -or -not $url) { throw "Could not read app_url from Terraform (are AWS credentials valid?)" }
+$yml = "- targets: [`"$url/`"]`n labels:`n env: production`n"
+New-Item -ItemType Directory -Force "$PSScriptRoot\prometheus\targets.d" | Out-Null
+[System.IO.File]::WriteAllText("$PSScriptRoot\prometheus\targets.d\production.yml", $yml)
+Write-Host "Production probe target: $url/"
\ No newline at end of file
diff --git a/scripts/bootstrap-tfstate.ps1 b/scripts/bootstrap-tfstate.ps1
new file mode 100644
index 0000000..1b8a02e
--- /dev/null
+++ b/scripts/bootstrap-tfstate.ps1
@@ -0,0 +1,52 @@
+# CloudPulse - one-time bootstrap of the Terraform remote state bucket.
+# Created outside Terraform on purpose: Terraform cannot keep its state in a
+# bucket it has not created yet. Idempotent: safe to re-run.
+# Run: powershell -ExecutionPolicy Bypass -File scripts\bootstrap-tfstate.ps1
+param([string]$Region = "us-east-1")
+
+function Invoke-Aws {
+ & aws @args
+ if ($LASTEXITCODE -ne 0) { throw "aws $($args -join ' ') failed" }
+}
+
+$account = (aws sts get-caller-identity --query Account --output text).Trim()
+$bucket = "cloudpulse-tfstate-$account"
+Write-Host "State bucket: $bucket"
+
+aws s3api head-bucket --bucket $bucket *> $null
+if ($LASTEXITCODE -ne 0) {
+ Invoke-Aws s3api create-bucket --bucket $bucket --region $Region | Out-Null
+ Write-Host "Created bucket"
+} else {
+ Write-Host "Bucket already exists"
+}
+
+# Versioning: every state write is kept; a bad apply can be rolled back
+Invoke-Aws s3api put-bucket-versioning --bucket $bucket --versioning-configuration Status=Enabled
+
+# Encryption at rest (SSE-S3). State contains secrets (e.g. the Django SECRET_KEY).
+Invoke-Aws s3api put-bucket-encryption --bucket $bucket --server-side-encryption-configuration "Rules=[{ApplyServerSideEncryptionByDefault={SSEAlgorithm=AES256},BucketKeyEnabled=true}]"
+
+# No public access, ever
+Invoke-Aws s3api put-public-access-block --bucket $bucket --public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
+
+# Deny any request not made over TLS
+$policy = @"
+{
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Sid": "DenyInsecureTransport",
+ "Effect": "Deny",
+ "Principal": "*",
+ "Action": "s3:*",
+ "Resource": ["arn:aws:s3:::$bucket", "arn:aws:s3:::$bucket/*"],
+ "Condition": {"Bool": {"aws:SecureTransport": "false"}}
+ }]
+}
+"@
+$policyFile = Join-Path $env:TEMP "cloudpulse-tfstate-policy.json"
+[System.IO.File]::WriteAllText($policyFile, $policy)
+Invoke-Aws s3api put-bucket-policy --bucket $bucket --policy "file://$policyFile"
+Remove-Item $policyFile
+
+Write-Host "Bucket $bucket ready: versioned, encrypted, public access blocked, TLS-only."
\ No newline at end of file
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100644
index 0000000..1b74584
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,188 @@
+#!/bin/bash
+# CloudPulse deploy script - runs ON the EC2 instance
+# (manually over SSH for now, via SSM Run Command from the CD pipeline).
+# - Pulls a dpaste image tag from ECR and replaces the running container
+# - Health-checks it and rolls back to the previous image on failure
+# - Runs Caddy as the TLS-terminating reverse proxy: automatic HTTPS via
+# Let's Encrypt on .sslip.io
+# Usage: sudo deploy.sh
+set -euo pipefail
+
+IMAGE_TAG="${1:?usage: deploy.sh }"
+REGION="us-east-1"
+REPO="cloudpulse"
+CONTAINER="dpaste"
+APP_PORT=8000
+DATA_DIR="/data"
+NETWORK="cloudpulse"
+PROXY="caddy"
+PROXY_IMAGE="caddy:2-alpine"
+LOG_GROUP="/cloudpulse/dpaste"
+SECRET_PARAM="/cloudpulse/django/secret_key"
+STATE_DIR="/opt/cloudpulse"
+ENV_FILE="${STATE_DIR}/app.env"
+CADDYFILE="${STATE_DIR}/Caddyfile"
+
+log() { echo "[deploy $(date -u +%H:%M:%S)] $*"; }
+
+# --- Instance metadata via IMDSv2 (session token required; IMDSv1 disabled) ---
+IMDS="http://169.254.169.254/latest"
+TOKEN=$(curl -sf -X PUT "${IMDS}/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 300")
+meta() { curl -sf -H "X-aws-ec2-metadata-token: ${TOKEN}" "${IMDS}/$1"; }
+ACCOUNT_ID=$(meta dynamic/instance-identity/document | grep -oP '"accountId"\s*:\s*"\K[0-9]+')
+PUBLIC_IP=$(meta meta-data/public-ipv4)
+APP_HOST="${PUBLIC_IP//./-}.sslip.io"
+
+REGISTRY="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"
+IMAGE="${REGISTRY}/${REPO}:${IMAGE_TAG}"
+log "Deploying ${IMAGE} for https://${APP_HOST}"
+
+# --- Pull image (instance role provides ECR read access; no stored credentials) ---
+# Preferred: Amazon ECR credential helper - fetches short-lived registry credentials
+# from the instance role on demand, so no token is written to /root/.docker/config.json.
+# Fallback: classic docker login if the helper package is unavailable.
+if command -v docker-credential-ecr-login >/dev/null 2>&1 \
+ || dnf install -y -q amazon-ecr-credential-helper >/dev/null 2>&1; then
+ mkdir -p /root/.docker
+ echo "{\"credHelpers\":{\"${REGISTRY}\":\"ecr-login\"}}" > /root/.docker/config.json
+ log "ECR auth: credential helper (no stored token)"
+else
+ aws ecr get-login-password --region "${REGION}" \
+ | docker login --username AWS --password-stdin "${REGISTRY}" >/dev/null 2>&1
+ log "ECR auth: docker login fallback"
+fi
+docker pull -q "${IMAGE}" >/dev/null
+
+# --- Runtime config: SECRET_KEY from SSM SecureString into a root-only env file ---
+SECRET_KEY=$(aws ssm get-parameter --region "${REGION}" --name "${SECRET_PARAM}" \
+ --with-decryption --query Parameter.Value --output text)
+mkdir -p "${STATE_DIR}"
+(
+ umask 077
+ cat > "${ENV_FILE}" </dev/null 2>&1 || docker network create "${NETWORK}" >/dev/null
+
+# awslogs driver options; $1 = stream prefix
+set_log_opts() {
+ LOG_OPTS=(--log-driver awslogs
+ --log-opt "awslogs-region=${REGION}"
+ --log-opt "awslogs-group=${LOG_GROUP}"
+ --log-opt "awslogs-stream=$1-$(date -u +%Y%m%d-%H%M%S)")
+}
+
+PREVIOUS_IMAGE=$(docker inspect --format '{{.Config.Image}}' "${CONTAINER}" 2>/dev/null || true)
+
+# dpaste listens only on the Docker network and on localhost (for health checks);
+# it is NOT reachable from the internet directly.
+start_container() {
+ docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
+ set_log_opts "${CONTAINER}"
+ docker run -d --name "${CONTAINER}" --restart unless-stopped \
+ --network "${NETWORK}" \
+ -p "127.0.0.1:${APP_PORT}:8000" \
+ --env-file "${ENV_FILE}" \
+ -v "dpaste_data:${DATA_DIR}" \
+ "${LOG_OPTS[@]}" \
+ "$1" >/dev/null
+}
+
+healthy() {
+ for _ in $(seq 1 30); do
+ if curl -sf -o /dev/null "http://localhost:${APP_PORT}/"; then return 0; fi
+ sleep 2
+ done
+ return 1
+}
+
+# Caddy: TLS termination + reverse proxy. Certificates persist in the caddy_data volume.
+ensure_proxy() {
+ cat > "${CADDYFILE}" </dev/null 2>&1
+ log "Caddy config reloaded"
+ else
+ docker rm -f "${PROXY}" >/dev/null 2>&1 || true
+ set_log_opts "${PROXY}"
+ docker pull -q "${PROXY_IMAGE}" >/dev/null
+ docker run -d --name "${PROXY}" --restart unless-stopped \
+ --network "${NETWORK}" \
+ -p 80:80 -p 443:443 \
+ -v "${CADDYFILE}:/etc/caddy/Caddyfile:ro" \
+ -v caddy_data:/data -v caddy_config:/config \
+ "${LOG_OPTS[@]}" \
+ "${PROXY_IMAGE}" >/dev/null
+ log "Caddy started"
+ fi
+}
+
+# Daily purge of expired snippets (dpaste's cleanup_snippets command).
+# systemd timer because Amazon Linux 2023 ships without cron.
+ensure_cleanup_timer() {
+ cat > /etc/systemd/system/cloudpulse-cleanup.service < /etc/systemd/system/cloudpulse-cleanup.timer </dev/null 2>&1
+ log "Cleanup timer active"
+}
+
+start_container "${IMAGE}"
+if healthy; then
+ log "Healthy: ${IMAGE}"
+ echo "${IMAGE}" > "${STATE_DIR}/current_image"
+ ensure_proxy
+ ensure_cleanup_timer
+ docker image prune -f >/dev/null
+ log "Live at https://${APP_HOST}"
+ exit 0
+fi
+
+log "Health check FAILED for ${IMAGE}. Last container logs:"
+docker logs --tail 30 "${CONTAINER}" 2>&1 || true
+
+if [ -n "${PREVIOUS_IMAGE}" ] && [ "${PREVIOUS_IMAGE}" != "${IMAGE}" ]; then
+ log "Rolling back to ${PREVIOUS_IMAGE}"
+ start_container "${PREVIOUS_IMAGE}"
+ if healthy; then
+ ensure_proxy
+ log "Rollback healthy: ${PREVIOUS_IMAGE}"
+ else
+ log "Rollback ALSO unhealthy"
+ fi
+fi
+exit 1
\ No newline at end of file
diff --git a/scripts/refresh-github-aws-secrets.ps1 b/scripts/refresh-github-aws-secrets.ps1
new file mode 100644
index 0000000..45083b8
--- /dev/null
+++ b/scripts/refresh-github-aws-secrets.ps1
@@ -0,0 +1,32 @@
+# CloudPulse - push the current AWS Academy session credentials to GitHub Actions secrets.
+# OIDC is not available in the Learner Lab (iam:CreateOpenIDConnectProvider is denied),
+# so CI/CD uses the temporary session credentials, which expire when the lab session ends.
+# Run at the start of every lab session, after updating ~/.aws/credentials:
+# powershell -ExecutionPolicy Bypass -File scripts\refresh-github-aws-secrets.ps1
+param([string]$Repo = "rayenmabrouk/cloudpulse")
+
+$credFile = Join-Path $HOME ".aws\credentials"
+$values = @{}
+foreach ($line in Get-Content $credFile) {
+ if ($line -match '^\s*(aws_access_key_id|aws_secret_access_key|aws_session_token)\s*=\s*(\S+)\s*$') {
+ $values[$Matches[1]] = $Matches[2]
+ }
+}
+foreach ($k in 'aws_access_key_id', 'aws_secret_access_key', 'aws_session_token') {
+ if (-not $values.ContainsKey($k)) { throw "$k not found in $credFile" }
+}
+
+# Refuse to upload expired credentials
+$account = aws sts get-caller-identity --query Account --output text
+if ($LASTEXITCODE -ne 0) { throw "Credentials in $credFile are not valid (lab session expired?)" }
+
+$map = @{
+ AWS_ACCESS_KEY_ID = 'aws_access_key_id'
+ AWS_SECRET_ACCESS_KEY = 'aws_secret_access_key'
+ AWS_SESSION_TOKEN = 'aws_session_token'
+}
+foreach ($secret in $map.Keys) {
+ gh secret set $secret --repo $Repo --body $values[$map[$secret]]
+ if ($LASTEXITCODE -ne 0) { throw "Failed to set $secret" }
+}
+Write-Host "GitHub secrets updated for $Repo (account $account). Valid until the lab session ends."
\ No newline at end of file
diff --git a/terraform/.gitignore b/terraform/.gitignore
new file mode 100644
index 0000000..edad16e
--- /dev/null
+++ b/terraform/.gitignore
@@ -0,0 +1,8 @@
+# Terraform
+.terraform/
+*.tfstate
+*.tfstate.*
+*.tfvars
+!*.tfvars.example
+tfplan
+crash.log
diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl
new file mode 100644
index 0000000..c3ac322
--- /dev/null
+++ b/terraform/.terraform.lock.hcl
@@ -0,0 +1,46 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "5.100.0"
+ constraints = "~> 5.0"
+ hashes = [
+ "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=",
+ "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
+ "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
+ "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
+ "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
+ "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
+ "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
+ "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
+ "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
+ "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
+ "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
+ "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
+ "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
+ "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+ version = "3.9.1"
+ constraints = "~> 3.6"
+ hashes = [
+ "h1:7uiStw0Rl9KOdX5UNMG/sp9nyadoD4LZekQTiYlYPhE=",
+ "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7",
+ "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26",
+ "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2",
+ "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1",
+ "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb",
+ "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f",
+ "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa",
+ "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097",
+ "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b",
+ "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc",
+ "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c",
+ ]
+}
diff --git a/terraform/backend.tf b/terraform/backend.tf
new file mode 100644
index 0000000..2802be8
--- /dev/null
+++ b/terraform/backend.tf
@@ -0,0 +1,12 @@
+# Remote state in S3 (bucket bootstrapped by scripts/bootstrap-tfstate.ps1):
+# versioned, SSE-S3 encrypted, public access blocked, TLS-only bucket policy.
+# use_lockfile = S3-native state locking (Terraform >= 1.10); no DynamoDB table needed.
+terraform {
+ backend "s3" {
+ bucket = "cloudpulse-tfstate-530008597446"
+ key = "dev/terraform.tfstate"
+ region = "us-east-1"
+ encrypt = true
+ use_lockfile = true
+ }
+}
\ No newline at end of file
diff --git a/terraform/main.tf b/terraform/main.tf
new file mode 100644
index 0000000..b39a3b5
--- /dev/null
+++ b/terraform/main.tf
@@ -0,0 +1,28 @@
+# ============================================================
+# CloudPulse - Root Module
+# Wires together: networking ? compute ? monitoring
+# Infrastructure: Rayen Mabrouk
+# ============================================================
+
+module "networking" {
+ source = "./modules/networking"
+
+ project_name = var.project_name
+ allowed_ssh_cidr = var.allowed_ssh_cidr
+}
+
+module "compute" {
+ source = "./modules/compute"
+
+ project_name = var.project_name
+ aws_region = var.aws_region
+ subnet_id = module.networking.public_subnet_id
+ security_group_id = module.networking.app_security_group_id
+}
+
+module "monitoring" {
+ source = "./modules/monitoring"
+
+ project_name = var.project_name
+ instance_id = module.compute.instance_id
+}
diff --git a/terraform/modules/compute/main.tf b/terraform/modules/compute/main.tf
new file mode 100644
index 0000000..330cbbd
--- /dev/null
+++ b/terraform/modules/compute/main.tf
@@ -0,0 +1,72 @@
+# ============================================================
+# CloudPulse ? Compute Module
+# Creates: ECR repository, EC2 instance with Docker
+# ============================================================
+
+# --- Find latest Amazon Linux 2023 AMI ---
+data "aws_ami" "amazon_linux" {
+ most_recent = true
+ owners = ["amazon"]
+
+ filter {
+ name = "name"
+ values = ["al2023-ami-2023.*-x86_64"]
+ }
+
+ filter {
+ name = "virtualization-type"
+ values = ["hvm"]
+ }
+}
+
+# --- Reference pre-existing Academy LabInstanceProfile ---
+data "aws_iam_instance_profile" "lab" {
+ name = "LabInstanceProfile"
+}
+
+# --- ECR Repository ---
+resource "aws_ecr_repository" "app" {
+ # checkov:skip=CKV_AWS_136:AES-256 encryption at rest; a customer-managed KMS key adds cost and key management with no benefit in a single-account lab
+ name = var.project_name
+ image_tag_mutability = "IMMUTABLE"
+ force_delete = true
+
+ image_scanning_configuration {
+ scan_on_push = true
+ }
+
+ tags = {
+ Name = "${var.project_name}-ecr"
+ }
+}
+
+# --- EC2 Instance ---
+resource "aws_instance" "app" {
+ # checkov:skip=CKV_AWS_135:t3 instance types are EBS-optimized by default; the flag does not apply
+ ami = data.aws_ami.amazon_linux.id
+ instance_type = var.instance_type
+ subnet_id = var.subnet_id
+ vpc_security_group_ids = [var.security_group_id]
+ iam_instance_profile = data.aws_iam_instance_profile.lab.name
+ key_name = var.key_name
+ monitoring = true # 1-minute CloudWatch metrics for faster alarms
+
+ # Enforce IMDSv2 ? prevents SSRF token theft
+ metadata_options {
+ http_endpoint = "enabled"
+ http_tokens = "required"
+ http_put_response_hop_limit = 1
+ }
+
+ user_data = file("${path.module}/user_data.sh")
+
+ root_block_device {
+ volume_size = 20
+ volume_type = "gp3"
+ encrypted = true
+ }
+
+ tags = {
+ Name = "${var.project_name}-server"
+ }
+}
diff --git a/terraform/modules/compute/outputs.tf b/terraform/modules/compute/outputs.tf
new file mode 100644
index 0000000..48d9c1d
--- /dev/null
+++ b/terraform/modules/compute/outputs.tf
@@ -0,0 +1,19 @@
+output "instance_id" {
+ description = "EC2 instance ID"
+ value = aws_instance.app.id
+}
+
+output "instance_public_ip" {
+ description = "Public IP of the EC2 instance"
+ value = aws_instance.app.public_ip
+}
+
+output "instance_public_dns" {
+ description = "Public DNS of the EC2 instance"
+ value = aws_instance.app.public_dns
+}
+
+output "ecr_repository_url" {
+ description = "ECR repository URL"
+ value = aws_ecr_repository.app.repository_url
+}
diff --git a/terraform/modules/compute/secrets.tf b/terraform/modules/compute/secrets.tf
new file mode 100644
index 0000000..a7f1dc8
--- /dev/null
+++ b/terraform/modules/compute/secrets.tf
@@ -0,0 +1,19 @@
+# --- Django SECRET_KEY: generated by Terraform, stored encrypted in SSM ---
+# Special characters limited to a shell/env-file safe set.
+resource "random_password" "django_secret_key" {
+ length = 64
+ special = true
+ override_special = "-_=+@#%"
+}
+
+resource "aws_ssm_parameter" "django_secret_key" {
+ # checkov:skip=CKV_AWS_337:encrypted with the AWS managed aws/ssm KMS key; a customer-managed key is not required in a single-account lab
+ name = "/${var.project_name}/django/secret_key"
+ description = "Django SECRET_KEY for dpaste (read by EC2 at deploy time)"
+ type = "SecureString"
+ value = random_password.django_secret_key.result
+
+ tags = {
+ Name = "${var.project_name}-django-secret-key"
+ }
+}
diff --git a/terraform/modules/compute/user_data.sh b/terraform/modules/compute/user_data.sh
new file mode 100644
index 0000000..606a2c7
--- /dev/null
+++ b/terraform/modules/compute/user_data.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+set -euo pipefail
+
+# Update system
+dnf update -y
+
+# Install Docker
+dnf install -y docker
+systemctl enable docker
+systemctl start docker
+
+# Add ec2-user to docker group
+usermod -aG docker ec2-user
+
+# Create application directory
+mkdir -p /opt/cloudpulse
+
+echo "CloudPulse bootstrap complete" | tee /var/log/cloudpulse-init.log
diff --git a/terraform/modules/compute/variables.tf b/terraform/modules/compute/variables.tf
new file mode 100644
index 0000000..7ea330e
--- /dev/null
+++ b/terraform/modules/compute/variables.tf
@@ -0,0 +1,37 @@
+variable "project_name" {
+ description = "Project name for resource naming"
+ type = string
+}
+
+variable "aws_region" {
+ description = "AWS region"
+ type = string
+}
+
+variable "subnet_id" {
+ description = "Subnet ID for the EC2 instance"
+ type = string
+}
+
+variable "security_group_id" {
+ description = "Security group ID for the EC2 instance"
+ type = string
+}
+
+variable "instance_type" {
+ description = "EC2 instance type"
+ type = string
+ default = "t3.micro"
+}
+
+variable "key_name" {
+ description = "SSH key pair name"
+ type = string
+ default = "vockey"
+}
+
+variable "app_port" {
+ description = "Application port"
+ type = number
+ default = 8000
+}
diff --git a/terraform/modules/compute/versions.tf b/terraform/modules/compute/versions.tf
new file mode 100644
index 0000000..11b2b8a
--- /dev/null
+++ b/terraform/modules/compute/versions.tf
@@ -0,0 +1,8 @@
+terraform {
+ required_providers {
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.6"
+ }
+ }
+}
diff --git a/terraform/modules/monitoring/main.tf b/terraform/modules/monitoring/main.tf
new file mode 100644
index 0000000..9b8aade
--- /dev/null
+++ b/terraform/modules/monitoring/main.tf
@@ -0,0 +1,47 @@
+# ============================================================
+# CloudPulse - Monitoring Module
+# Creates: CloudWatch log group, CPU alarm, status check alarm
+# ============================================================
+
+resource "aws_cloudwatch_log_group" "app" {
+ # checkov:skip=CKV_AWS_158:CloudWatch Logs encrypts log data at rest by default; a customer-managed key would add a key policy to maintain
+ # checkov:skip=CKV_AWS_338:7-day retention chosen deliberately to limit cost in a lab environment
+ name = "/cloudpulse/dpaste"
+ retention_in_days = 7
+
+ tags = {
+ Name = "${var.project_name}-logs"
+ }
+}
+
+resource "aws_cloudwatch_metric_alarm" "cpu_high" {
+ alarm_name = "${var.project_name}-cpu-high"
+ comparison_operator = "GreaterThanThreshold"
+ evaluation_periods = 2
+ metric_name = "CPUUtilization"
+ namespace = "AWS/EC2"
+ period = 300
+ statistic = "Average"
+ threshold = 80
+ alarm_description = "CPU utilization exceeds 80% for 10 minutes"
+
+ dimensions = {
+ InstanceId = var.instance_id
+ }
+}
+
+resource "aws_cloudwatch_metric_alarm" "status_check" {
+ alarm_name = "${var.project_name}-status-check"
+ comparison_operator = "GreaterThanThreshold"
+ evaluation_periods = 2
+ metric_name = "StatusCheckFailed"
+ namespace = "AWS/EC2"
+ period = 300
+ statistic = "Maximum"
+ threshold = 0
+ alarm_description = "EC2 instance status check failed"
+
+ dimensions = {
+ InstanceId = var.instance_id
+ }
+}
diff --git a/terraform/modules/monitoring/outputs.tf b/terraform/modules/monitoring/outputs.tf
new file mode 100644
index 0000000..918dbe4
--- /dev/null
+++ b/terraform/modules/monitoring/outputs.tf
@@ -0,0 +1,4 @@
+output "log_group_name" {
+ description = "CloudWatch log group name"
+ value = aws_cloudwatch_log_group.app.name
+}
diff --git a/terraform/modules/monitoring/variables.tf b/terraform/modules/monitoring/variables.tf
new file mode 100644
index 0000000..02ee3b7
--- /dev/null
+++ b/terraform/modules/monitoring/variables.tf
@@ -0,0 +1,9 @@
+variable "project_name" {
+ description = "Project name for resource naming"
+ type = string
+}
+
+variable "instance_id" {
+ description = "EC2 instance ID to monitor"
+ type = string
+}
diff --git a/terraform/modules/networking/main.tf b/terraform/modules/networking/main.tf
new file mode 100644
index 0000000..dae483d
--- /dev/null
+++ b/terraform/modules/networking/main.tf
@@ -0,0 +1,122 @@
+# ============================================================
+# CloudPulse - Networking Module
+# Creates: VPC, public subnet, internet gateway, route table,
+# security group
+# ============================================================
+
+# --- VPC ---
+resource "aws_vpc" "main" {
+ # checkov:skip=CKV2_AWS_11:VPC flow logs need a delivery IAM role, which cannot be created in AWS Academy; documented as a production requirement
+ cidr_block = var.vpc_cidr
+ enable_dns_support = true
+ enable_dns_hostnames = true
+
+ tags = {
+ Name = "${var.project_name}-vpc"
+ }
+}
+
+# --- Public Subnet ---
+data "aws_availability_zones" "available" {
+ # checkov:skip=CKV_AWS_394:single-AZ deployment that only uses names[0]
+ state = "available"
+}
+
+resource "aws_subnet" "public" {
+ # checkov:skip=CKV_AWS_130:public subnet by design (no NAT gateway, for cost); production would use a private subnet behind a load balancer
+ vpc_id = aws_vpc.main.id
+ cidr_block = var.public_subnet_cidr
+ map_public_ip_on_launch = true
+ availability_zone = data.aws_availability_zones.available.names[0]
+
+ tags = {
+ Name = "${var.project_name}-public-subnet"
+ }
+}
+
+# --- Internet Gateway ---
+resource "aws_internet_gateway" "main" {
+ vpc_id = aws_vpc.main.id
+
+ tags = {
+ Name = "${var.project_name}-igw"
+ }
+}
+
+# --- Route Table ---
+resource "aws_route_table" "public" {
+ vpc_id = aws_vpc.main.id
+
+ route {
+ cidr_block = "0.0.0.0/0"
+ gateway_id = aws_internet_gateway.main.id
+ }
+
+ tags = {
+ Name = "${var.project_name}-public-rt"
+ }
+}
+
+resource "aws_route_table_association" "public" {
+ subnet_id = aws_subnet.public.id
+ route_table_id = aws_route_table.public.id
+}
+
+# --- Security Group ---
+resource "aws_security_group" "app" {
+ # checkov:skip=CKV_AWS_260:port 80 is required for the HTTP to HTTPS redirect and Let's Encrypt HTTP-01 validation
+ # checkov:skip=CKV_AWS_382:outbound access needed for ECR, SSM, Let's Encrypt and OS packages; production would use VPC endpoints and restricted egress
+ # checkov:skip=CKV2_AWS_5:false positive - attached to the EC2 instance in the compute module
+ name = "${var.project_name}-app-sg"
+ description = "Security group for CloudPulse application"
+ vpc_id = aws_vpc.main.id
+
+ # SSH - restricted to your IP only
+ ingress {
+ description = "SSH from allowed IP"
+ from_port = 22
+ to_port = 22
+ protocol = "tcp"
+ cidr_blocks = [var.allowed_ssh_cidr]
+ }
+
+ # HTTP - Caddy redirects to HTTPS and answers Let's Encrypt HTTP-01 challenges
+ ingress {
+ description = "HTTP redirect to HTTPS and ACME challenge"
+ from_port = 80
+ to_port = 80
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ # HTTPS - public entry point; TLS terminated by Caddy on the instance
+ ingress {
+ description = "HTTPS"
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ # All outbound
+ egress {
+ description = "All outbound traffic"
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+
+ tags = {
+ Name = "${var.project_name}-app-sg"
+ }
+}
+
+# --- Default security group: all rules removed (nothing should use it) ---
+resource "aws_default_security_group" "default" {
+ vpc_id = aws_vpc.main.id
+
+ tags = {
+ Name = "${var.project_name}-default-sg-locked"
+ }
+}
\ No newline at end of file
diff --git a/terraform/modules/networking/outputs.tf b/terraform/modules/networking/outputs.tf
new file mode 100644
index 0000000..fcb8bc3
--- /dev/null
+++ b/terraform/modules/networking/outputs.tf
@@ -0,0 +1,14 @@
+output "vpc_id" {
+ description = "ID of the VPC"
+ value = aws_vpc.main.id
+}
+
+output "public_subnet_id" {
+ description = "ID of the public subnet"
+ value = aws_subnet.public.id
+}
+
+output "app_security_group_id" {
+ description = "ID of the application security group"
+ value = aws_security_group.app.id
+}
diff --git a/terraform/modules/networking/variables.tf b/terraform/modules/networking/variables.tf
new file mode 100644
index 0000000..690f31f
--- /dev/null
+++ b/terraform/modules/networking/variables.tf
@@ -0,0 +1,27 @@
+variable "project_name" {
+ description = "Project name for resource naming"
+ type = string
+}
+
+variable "vpc_cidr" {
+ description = "CIDR block for the VPC"
+ type = string
+ default = "10.0.0.0/16"
+}
+
+variable "public_subnet_cidr" {
+ description = "CIDR block for the public subnet"
+ type = string
+ default = "10.0.1.0/24"
+}
+
+variable "allowed_ssh_cidr" {
+ description = "CIDR block allowed to SSH into EC2"
+ type = string
+}
+
+variable "app_port" {
+ description = "Application port exposed by the container"
+ type = number
+ default = 8000
+}
diff --git a/terraform/outputs.tf b/terraform/outputs.tf
new file mode 100644
index 0000000..62805df
--- /dev/null
+++ b/terraform/outputs.tf
@@ -0,0 +1,19 @@
+output "instance_public_ip" {
+ description = "Public IP of the EC2 instance running dpaste"
+ value = module.compute.instance_public_ip
+}
+
+output "ecr_repository_url" {
+ description = "ECR repository URL for Docker image push"
+ value = module.compute.ecr_repository_url
+}
+
+output "log_group_name" {
+ description = "CloudWatch log group for container logs"
+ value = module.monitoring.log_group_name
+}
+
+output "app_url" {
+ description = "Public HTTPS URL (sslip.io hostname derived from the instance IP)"
+ value = "https://${replace(module.compute.instance_public_ip, ".", "-")}.sslip.io"
+}
\ No newline at end of file
diff --git a/terraform/providers.tf b/terraform/providers.tf
new file mode 100644
index 0000000..8772ef3
--- /dev/null
+++ b/terraform/providers.tf
@@ -0,0 +1,19 @@
+# ============================================================
+# CloudPulse - Terraform Provider Configuration
+# Infrastructure: Rayen Mabrouk
+# ============================================================
+
+terraform {
+ required_version = ">= 1.10"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.0"
+ }
+ }
+}
+
+provider "aws" {
+ region = var.aws_region
+}
diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example
new file mode 100644
index 0000000..8a2804c
--- /dev/null
+++ b/terraform/terraform.tfvars.example
@@ -0,0 +1,3 @@
+aws_region = "us-east-1"
+project_name = "cloudpulse"
+allowed_ssh_cidr = "YOUR_PUBLIC_IP/32"
diff --git a/terraform/variables.tf b/terraform/variables.tf
new file mode 100644
index 0000000..4404e8e
--- /dev/null
+++ b/terraform/variables.tf
@@ -0,0 +1,20 @@
+# ============================================================
+# CloudPulse - Root Variables
+# ============================================================
+
+variable "aws_region" {
+ description = "AWS region for all resources"
+ type = string
+ default = "us-east-1"
+}
+
+variable "project_name" {
+ description = "Project name used for resource naming and tagging"
+ type = string
+ default = "cloudpulse"
+}
+
+variable "allowed_ssh_cidr" {
+ description = "CIDR block allowed to SSH into EC2 (your IP)"
+ type = string
+}