Home
← Back
2026

Journal API

A journaling API on FastAPI and PostgreSQL, deployed end to end on AWS with Kubernetes, Terraform and a GitHub OIDC pipeline. The repository is public, and the whole thing was torn down in November 2026 with the procedure written down.

Role
Sole author — cloud engineering, infrastructure and pipeline
Status
Torn down on 06/11/2026. 99 resources destroyed. Not running on AWS.

Stack

Service
Python · FastAPI · PostgreSQL 16
Infrastructure
79 recursos de Terraform · EC2 t3.small con k3s · EKS
Delivery
GitHub Actions con OIDC · sin claves permanentes
Security
Secrets Manager acotado a 2 ARNs · CodeQL · gitleaks
Observability
ADOT · OpenTelemetry · X-Ray · CloudWatch · 15 series de métricas
Licence
MIT

Architecture

  push to main
      │
      ▼
  ┌──────────────────────────────────────────────────┐
  │ GitHub Actions                                    │
  │  test    ruff + pytest against a service Postgres │
  │          gitleaks over the full history          │
  │  build   Docker image tagged with the SHA         │
  │          push to ECR                             │
  │  deploy  SSM Run Command → rollout →             │
  │          verification against /version           │
  └───────────────┬──────────────────────────────────┘
                  │ OIDC: no keys in the repository
                  ▼
  ┌──────────────────────────────────────────────────┐
  │ EC2 t3.small · k3s                               │
  │  journal-api namespace                           │
  │  ADOT + Fluent Bit · Postgres · Caddy with TLS   │
  │  Secrets Manager → init container → tmpfs        │
  └───────────────┬──────────────────────────────────┘
                  │ HTTPS
                  ▼
              /version → the deployed SHA

Problem

The capstone asked for an API deployed on AWS with CI/CD, infrastructure as code and observability. The real problem was not writing the API: it was making the delivery reproducible, and making sure nobody had to go looking for a credential in order to deploy.

There was a second half to the job that almost nobody documents: once a project ends, the infrastructure has to go back to where it started. A `terraform destroy` on its own does not do that.

Approach

Everything that can live in Terraform does. The VPC, the IAM roles, the security group, the alarm topic, the dashboard, the 13 managed AWS Config rules, the budget: 79 resources, versioned, with every secret behind a placeholder Terraform never fills in.

Credentials never travel through state. Terraform creates the secret's shell — name, ARN, encryption, policy — but never its value. The value is seeded out of band, from SSM Parameter Store into Secrets Manager, and an init container with its own identity pulls it down. The instance identity carries an explicit Deny on those two ARNs: it cannot read them, only delegate.

And the pipeline holds no long-lived keys. GitHub Actions authenticates to AWS through OIDC, with a role whose permissions are minimal and whose trust is scoped to this repository, the `main` branch and the `production` environment.

Result

83 commits. 79 Terraform resources. The deploy verifies against /version, which returns the commit SHA: four independent values — the local commit, the image tag in ECR, the tag Kubernetes is running and the SHA the API answers with — have to match exactly, and the job fails when they don't.

The observability is not a dashboard: ADOT over OTLP into X-Ray and CloudWatch, Fluent Bit for pod logs, custom spans (`db.entries.*`, `llm.analyze_entry`), 15 metric series with p50 and p95, a dashboard, and alarms on 5xx rate and latency routed to SNS. In AWS Config, 13 managed rules — 8 conformity and 5 threat — evaluate the posture of the account; the first evaluation produced 3 real findings, and all three were closed.

And the teardown was carried out in full on 06/11/2026, after the platform verification passed and the project owner confirmed it explicitly: 99 resources destroyed by Terraform, 66 ECR images deleted, 6107 S3 versions emptied by hand, and the resources living outside state — budgets and SSM parameters — cleaned up too. The full procedure, with what was found and what was not, is in `docs/cleanup.md`.

Known limits

The database runs in a public subnet. The reason was cost: a private subnet with a NAT Gateway costs more than the whole instance on an account with no real traffic. It is a cost decision, not a design one, and with real traffic it would need revisiting.

The destroy is not atomic, because the ECR repository has no `force_delete`: the images go first and the repository second, in two passes. And the AWS budgets and some SSM parameters lived outside Terraform, so the destroy did not touch them and they were cleaned up by hand.

On k3s any pod can reach IMDS and obtain the instance profile credentials, so the `ExternalId` protects against accidental use, not against an attacker. Real per-pod isolation needs AWS to validate the token cryptographically — IRSA or EKS Pod Identity — and that requires a public OIDC issuer, which is not viable with an `sslip.io` IP that Terraform reassigns on every `destroy`.

Links