diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5569045..16f5b1c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -36,6 +36,9 @@ repos:
# Helm charts under infra/; current YAML files are all
# single-document and unaffected.
args: [--allow-multiple-documents]
+ # docker-compose.override.yml uses Compose's custom `!override`
+ # tag, which strict YAML parsers (PyYAML) cannot resolve.
+ exclude: ^infra/docker-compose\.override\.yml$
- id: check-json
# tsconfig*.json files use JSONC (comments allowed), which is
# valid for TypeScript but rejected by strict JSON parsers.
diff --git a/README.md b/README.md
index 07893de..1dae6ea 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ repo/
│ └── generated/ # ⚠ Generated — do not edit by hand
├── web-client/ # React SPA (Vite, TypeScript)
│ └── src/api.ts # ⚠ Generated — do not edit by hand
-├── infra/ # docker-compose, Traefik config, Helm/Terraform
+├── infra/ # docker-compose, Traefik config, Terraform, Ansible
└── .github/workflows/ # CI/CD pipelines
```
@@ -52,7 +52,7 @@ The Spring Boot services and the GenAI service share a **PostgreSQL** database.
| GenAI Service | `/api/v1/helper/…` | 5000 | Python 3.12, Flask, LangChain |
| Web Client | `/` | 8080 | React, Vite |
| Swagger UI | `/docs` | 8080 | swaggerapi/swagger-ui |
-| Traefik dashboard | `http://localhost:8080` | — | Traefik v3 |
+| Traefik dashboard | `http://localhost:8080` (local only) | — | Traefik v3 |
| PostgreSQL | internal only | 5432 | postgres:15 |
## Code Generation
@@ -114,6 +114,98 @@ git push --no-verify
The full hook configuration lives in [`.pre-commit-config.yaml`](.pre-commit-config.yaml)
and the helper scripts under [`scripts/hooks/`](scripts/hooks/).
+## Running Locally
+
+Spin up the full stack on your machine with Docker Compose:
+
+```bash
+cd infra
+docker compose up -d --build
+```
+
+This auto-merges [`infra/docker-compose.override.yml`](infra/docker-compose.override.yml),
+which strips TLS / Let's Encrypt / Host-based routing from the base file so
+everything is reachable on plain HTTP:
+
+| URL | Service |
+|---|---|
+| | Web client |
+| | Swagger UI |
+| | APIs (organization, members, events, feedback, finance, letters, helper) |
+| | Traefik dashboard |
+
+> **Do not run** `docker compose -f infra/docker-compose.yml up` locally — that
+> skips the override, causing Traefik to request a real Let's Encrypt cert for
+> the production hostname from your laptop. Failed challenges count toward the
+> production rate limit.
+
+Tear down:
+```bash
+cd infra && docker compose down # keeps the postgres volume
+cd infra && docker compose down -v # wipes the postgres volume too
+```
+
+## Production Deployment
+
+The stack runs on a single Azure VM in **UAE North**, fronted by Traefik with a
+real TLS certificate from Let's Encrypt (production CA). Everything is
+automated; no manual VM access is required for normal deploys.
+
+**Live URL:**
+
+### Infrastructure stack
+
+| Layer | Tool | What it does |
+|---|---|---|
+| Provisioning | **Terraform** (AzureRM ~> 4.0) | Resource group, VNet, NSG (22/80/443), static public IP + free Azure FQDN, Ubuntu 24.04 VM |
+| Configuration | **Ansible** | Installs Docker, clones repo, writes `.env`, runs `docker compose up` |
+| CI/CD | **GitHub Actions** (OIDC, no client secrets) | `infra.yml` (manual: plan/apply/destroy) and `cd.yml` (auto on push to `main`) |
+| Remote state | **Azure Blob Storage** (`stteamdevoopstfstate/tfstate`) | Shared, locked Terraform state — survives between CI runs |
+| TLS | **Let's Encrypt** (HTTP-01 via Traefik) | Cert persisted in a Docker volume; auto-renewed |
+
+### GitHub Actions workflows
+
+- **`infra` workflow** — manual (`workflow_dispatch`). Choose `plan`, `apply`, or `destroy`.
+- **`cd` workflow** — runs automatically on every push to `main` (and is also `workflow_dispatch`-able). Deploys the current `main` to the VM via Ansible.
+
+### Required GitHub secrets / variables
+
+| Kind | Name | Purpose |
+|---|---|---|
+| Variable | `AZURE_CLIENT_ID` | OIDC app registration (Service Principal) |
+| Variable | `AZURE_TENANT_ID` | Azure AD tenant |
+| Variable | `AZURE_SUBSCRIPTION_ID` | Target subscription |
+| Secret | `VM_SSH_PUBLIC_KEY` | Public key planted on the VM by Terraform |
+| Secret | `SSH_PRIVATE_KEY` | Matching private key for Ansible to SSH in |
+| Secret | `VM_HOST` | Host Ansible connects to — use the FQDN above |
+| Secret | `GENAI_ENV_CONTENT` | Contents of `services/py-genai-helper/.env` |
+
+The OIDC service principal needs `Contributor` on the subscription (to manage
+resources in `rg-team-devoops`) and `Storage Blob Data Contributor` on the
+state account `stteamdevoopstfstate` (to read/write tfstate).
+
+### Typical workflow
+
+1. **Change infra** → push to `main` (or any branch), trigger `infra` workflow with `apply`.
+2. **Change app code** → merge to `main`; `cd` runs automatically and redeploys.
+3. **Tear down** → trigger `infra` workflow with `destroy`.
+
+### Running Terraform locally
+
+```bash
+az login
+az account set --subscription
+export ARM_SUBSCRIPTION_ID=
+export ARM_USE_AZUREAD=true
+cd infra/terraform
+echo "admin_ssh_public_key = \"$(cat ~/.ssh/team-devoops-azure.pub)\"" > terraform.tfvars
+terraform init
+terraform plan
+```
+
+Local and CI share the same remote state, so do not run `apply` in both at the
+same time (the backend's blob lease will block one, but coordinate anyway).
+
## Docs
- [Problem Statement](docs/problem-statement.md)
diff --git a/infra/docker-compose.override.yml b/infra/docker-compose.override.yml
new file mode 100644
index 0000000..06971f3
--- /dev/null
+++ b/infra/docker-compose.override.yml
@@ -0,0 +1,108 @@
+# Local development override.
+#
+# Compose auto-merges this file ONLY when running `docker compose` with no `-f`
+# flags. The production Ansible playbook always passes `-f infra/docker-compose.yml`
+# explicitly, so this override is never used on the VM.
+#
+# Usage:
+# cd infra && docker compose up -d --build
+#
+# What this override does:
+# * Removes TLS (no Let's Encrypt, no HTTPS, no 443) -- local has no public DNS
+# * Strips the Host(...) requirement from every router so localhost works
+# * Disables the HTTP -> HTTPS redirect so plain http://localhost works
+#
+# Access locally:
+# http://localhost/ web client
+# http://localhost/docs Swagger UI
+# http://localhost/api/v1//... APIs
+# http://localhost:8080 Traefik dashboard
+
+services:
+ traefik:
+ command:
+ - "--api.insecure=true"
+ - "--providers.docker=true"
+ - "--providers.docker.exposedByDefault=false"
+ - "--providers.docker.network=proxy"
+ - "--entrypoints.web.address=:80"
+ ports: !override
+ - "80:80"
+ - "8080:8080"
+
+ py-genai-helper:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.py-genai-helper.entrypoints=web"
+ - "traefik.http.routers.py-genai-helper.rule=PathPrefix(`/api/v1/helper`)"
+ - "traefik.http.middlewares.helper-stripprefix.stripprefix.prefixes=/api/v1/helper"
+ - "traefik.http.routers.py-genai-helper.middlewares=helper-stripprefix"
+ - "traefik.http.services.py-genai-helper.loadbalancer.server.port=5000"
+
+ organization-service:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.organization-service.entrypoints=web"
+ - "traefik.http.routers.organization-service.rule=PathPrefix(`/api/v1/organization`)"
+ - "traefik.http.middlewares.organization-stripprefix.stripprefix.prefixes=/api/v1/organization"
+ - "traefik.http.routers.organization-service.middlewares=organization-stripprefix"
+ - "traefik.http.services.organization-service.loadbalancer.server.port=8080"
+
+ member-service:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.member-service.entrypoints=web"
+ - "traefik.http.routers.member-service.rule=PathPrefix(`/api/v1/members`)"
+ - "traefik.http.middlewares.member-stripprefix.stripprefix.prefixes=/api/v1/members"
+ - "traefik.http.routers.member-service.middlewares=member-stripprefix"
+ - "traefik.http.services.member-service.loadbalancer.server.port=8080"
+
+ event-service:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.event-service.entrypoints=web"
+ - "traefik.http.routers.event-service.rule=PathPrefix(`/api/v1/events`)"
+ - "traefik.http.middlewares.event-stripprefix.stripprefix.prefixes=/api/v1/events"
+ - "traefik.http.routers.event-service.middlewares=event-stripprefix"
+ - "traefik.http.services.event-service.loadbalancer.server.port=8080"
+
+ feedback-service:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.feedback-service.entrypoints=web"
+ - "traefik.http.routers.feedback-service.rule=PathPrefix(`/api/v1/feedback`)"
+ - "traefik.http.middlewares.feedback-stripprefix.stripprefix.prefixes=/api/v1/feedback"
+ - "traefik.http.routers.feedback-service.middlewares=feedback-stripprefix"
+ - "traefik.http.services.feedback-service.loadbalancer.server.port=8080"
+
+ finance-service:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.finance-service.entrypoints=web"
+ - "traefik.http.routers.finance-service.rule=PathPrefix(`/api/v1/finance`)"
+ - "traefik.http.middlewares.finance-stripprefix.stripprefix.prefixes=/api/v1/finance"
+ - "traefik.http.routers.finance-service.middlewares=finance-stripprefix"
+ - "traefik.http.services.finance-service.loadbalancer.server.port=8080"
+
+ letter-service:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.letter-service.entrypoints=web"
+ - "traefik.http.routers.letter-service.rule=PathPrefix(`/api/v1/letters`)"
+ - "traefik.http.middlewares.letter-stripprefix.stripprefix.prefixes=/api/v1/letters"
+ - "traefik.http.routers.letter-service.middlewares=letter-stripprefix"
+ - "traefik.http.services.letter-service.loadbalancer.server.port=8080"
+
+ api-docs:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.api-docs.entrypoints=web"
+ - "traefik.http.routers.api-docs.rule=PathPrefix(`/docs`)"
+ - "traefik.http.services.api-docs.loadbalancer.server.port=8080"
+
+ web-client:
+ labels: !override
+ - "traefik.enable=true"
+ - "traefik.http.routers.web-client.entrypoints=web"
+ - "traefik.http.routers.web-client.rule=PathPrefix(`/`)"
+ - "traefik.http.services.web-client.loadbalancer.server.port=8080"
diff --git a/web-client/src/features/payments/client.ts b/web-client/src/features/payments/client.ts
index 29e2d1a..6c286ce 100644
--- a/web-client/src/features/payments/client.ts
+++ b/web-client/src/features/payments/client.ts
@@ -1,5 +1,5 @@
import axios from 'axios'
export const paymentsClient = axios.create({
- baseURL: '/api/v1/finances',
+ baseURL: '/api/v1/finance',
})