Monorepo Full-Stack
Overview¶
This is a complete, working example of a production-ready full-stack monorepo called acme-platform - a task management platform with a Python API backend, TypeScript React frontend, and Terraform infrastructure. It demonstrates cross-technology integration patterns including multi-language CI/CD, shared Docker Compose environments, and contract-based infrastructure modules.
Project Purpose: A unified repository containing all components of a task management platform with independent validation pipelines and coordinated deployment.
Repository Structure¶
acme-platform/
├── backend/
│ ├── src/
│ │ └── acme_api/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ ├── config.py
│ │ ├── models.py
│ │ ├── routes/
│ │ │ ├── __init__.py
│ │ │ ├── health.py
│ │ │ └── tasks.py
│ │ └── schemas.py
│ ├── tests/
│ │ ├── conftest.py
│ │ └── test_tasks.py
│ ├── pyproject.toml
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── components/
│ │ │ └── TaskList.tsx
│ │ ├── services/
│ │ │ └── api.ts
│ │ └── types/
│ │ └── index.ts
│ ├── tests/
│ │ └── TaskList.test.tsx
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ └── Dockerfile
├── infra/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── versions.tf
│ ├── environments/
│ │ ├── dev.tfvars
│ │ └── prod.tfvars
│ └── CONTRACT.md
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
├── .pre-commit-config.yaml
├── .editorconfig
├── docker-compose.yml
├── Makefile
└── README.md
Makefile¶
.PHONY: help up down test lint backend-test frontend-test infra-validate
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
up: ## Start all services locally
docker compose up -d --build
down: ## Stop all services
docker compose down -v
test: backend-test frontend-test infra-validate ## Run all tests
backend-test: ## Run backend tests
cd backend && pip install -e ".[dev]" && pytest
frontend-test: ## Run frontend tests
cd frontend && npm ci && npm test
infra-validate: ## Validate Terraform configuration
cd infra && terraform init -backend=false && terraform validate && terraform fmt -check
lint: ## Run all linters
cd backend && black --check . && flake8 src/ tests/
cd frontend && npm run lint && npm run format:check
cd infra && terraform fmt -check -recursive
clean: ## Remove build artifacts
rm -rf backend/dist frontend/dist
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type d -name node_modules -exec rm -rf {} +
docker-compose.yml¶
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://acme:acme@db:5432/acme
- ENVIRONMENT=development
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 10s
timeout: 5s
retries: 3
frontend:
build: ./frontend
ports:
- "3000:80"
depends_on:
backend:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=acme
- POSTGRES_PASSWORD=acme
- POSTGRES_DB=acme
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U acme"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
.editorconfig¶
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{ts,tsx,js,jsx,json,css,yml,yaml}]
indent_size = 2
[*.{tf,tfvars}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
.pre-commit-config.yaml¶
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args: [--unsafe]
- id: check-json
- id: check-added-large-files
args: [--maxkb=1000]
- id: detect-private-key
# Python (backend)
- repo: https://github.com/psf/black
rev: "24.10.0"
hooks:
- id: black
files: ^backend/
- repo: https://github.com/PyCQA/flake8
rev: "7.1.1"
hooks:
- id: flake8
files: ^backend/
args: [--max-line-length=100, --extend-ignore=E203]
# Terraform (infra)
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.96.3
hooks:
- id: terraform_fmt
files: ^infra/
- id: terraform_validate
files: ^infra/
backend/pyproject.toml¶
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "acme-api"
version = "1.0.0"
description = "Backend API for the ACME platform"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115,<1",
"uvicorn[standard]>=0.34,<1",
"sqlalchemy>=2.0,<3",
"psycopg2-binary>=2.9,<3",
"pydantic>=2.10,<3",
"alembic>=1.14,<2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3,<9",
"pytest-cov>=6.0,<7",
"httpx>=0.28,<1",
"black>=24.10,<25",
"flake8>=7.1,<8",
"mypy>=1.13,<2",
]
[tool.black]
line-length = 100
target-version = ["py311"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --cov=src/acme_api --cov-report=term-missing"
backend/src/acme_api/__init__.py¶
"""
@module acme_api
@description Backend API for the ACME task management platform
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-15
@status stable
"""
backend/src/acme_api/config.py¶
"""Application configuration using Pydantic settings."""
from __future__ import annotations
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
environment: str = "development"
database_url: str = "postgresql://acme:acme@localhost:5432/acme"
allowed_origins: list[str] = ["http://localhost:3000"]
log_level: str = "INFO"
model_config = {"env_prefix": "", "case_sensitive": False}
settings = Settings()
backend/src/acme_api/models.py¶
"""SQLAlchemy database models."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Column, DateTime, Enum, Integer, String, Text, create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from acme_api.config import settings
class Base(DeclarativeBase):
"""Base class for all models."""
class Task(Base):
"""Task entity."""
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=True)
status = Column(
Enum("pending", "in_progress", "completed", "cancelled", name="task_status"),
nullable=False,
default="pending",
)
priority = Column(
Enum("low", "medium", "high", "critical", name="task_priority"),
nullable=False,
default="medium",
)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
"""Serialize to dictionary."""
return {
"id": self.id,
"title": self.title,
"description": self.description,
"status": self.status,
"priority": self.priority,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(bind=engine)
def get_db() -> Session:
"""Yield a database session, closing it after use."""
db = SessionLocal()
try:
yield db
finally:
db.close()
backend/src/acme_api/schemas.py¶
"""Pydantic request and response schemas."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class TaskStatus(str, Enum):
"""Valid task statuses."""
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class TaskPriority(str, Enum):
"""Valid task priorities."""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
priority: TaskPriority = TaskPriority.MEDIUM
class TaskUpdate(BaseModel):
"""Schema for updating a task."""
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
status: Optional[TaskStatus] = None
priority: Optional[TaskPriority] = None
class TaskResponse(BaseModel):
"""Schema for task API responses."""
id: int
title: str
description: Optional[str]
status: TaskStatus
priority: TaskPriority
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class PaginatedTasks(BaseModel):
"""Paginated list of tasks."""
tasks: list[TaskResponse]
total: int
page: int
pages: int
backend/src/acme_api/routes/health.py¶
"""Health check endpoints."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
async def health_check() -> dict:
"""Shallow health check."""
return {"status": "healthy", "service": "acme-api"}
backend/src/acme_api/routes/tasks.py¶
"""Task CRUD endpoints."""
from __future__ import annotations
from math import ceil
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from acme_api.models import Task, get_db
from acme_api.schemas import (
PaginatedTasks,
TaskCreate,
TaskResponse,
TaskStatus,
TaskUpdate,
)
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
@router.get("", response_model=PaginatedTasks)
async def list_tasks(
status: TaskStatus | None = None,
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
) -> PaginatedTasks:
"""List tasks with optional status filter and pagination."""
query = db.query(Task)
if status:
query = query.filter(Task.status == status.value)
total = query.count()
tasks = query.order_by(Task.created_at.desc()).offset((page - 1) * per_page).limit(per_page).all()
return PaginatedTasks(
tasks=[TaskResponse.model_validate(t) for t in tasks],
total=total,
page=page,
pages=ceil(total / per_page) if total > 0 else 0,
)
@router.post("", response_model=TaskResponse, status_code=201)
async def create_task(payload: TaskCreate, db: Session = Depends(get_db)) -> TaskResponse:
"""Create a new task."""
task = Task(
title=payload.title,
description=payload.description,
priority=payload.priority.value,
)
db.add(task)
db.commit()
db.refresh(task)
return TaskResponse.model_validate(task)
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(task_id: int, db: Session = Depends(get_db)) -> TaskResponse:
"""Retrieve a task by ID."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse.model_validate(task)
@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task(
task_id: int, payload: TaskUpdate, db: Session = Depends(get_db)
) -> TaskResponse:
"""Partially update a task."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(task, field, value.value if hasattr(value, "value") else value)
db.commit()
db.refresh(task)
return TaskResponse.model_validate(task)
@router.delete("/{task_id}", status_code=204)
async def delete_task(task_id: int, db: Session = Depends(get_db)) -> None:
"""Delete a task."""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
db.delete(task)
db.commit()
backend/src/acme_api/main.py¶
"""FastAPI application entrypoint."""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from acme_api.config import settings
from acme_api.routes import health, tasks
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(
title="ACME Platform API",
version="1.0.0",
docs_url="/docs" if settings.environment != "production" else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(tasks.router)
return app
app = create_app()
backend/tests/conftest.py¶
"""Shared pytest fixtures for backend tests."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from acme_api.main import create_app
from acme_api.models import Base, get_db
@pytest.fixture()
def db_session():
"""Create an in-memory SQLite session for testing."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine)
session = session_factory()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(engine)
@pytest.fixture()
def client(db_session):
"""Provide a FastAPI test client with overridden DB dependency."""
app = create_app()
app.dependency_overrides[get_db] = lambda: db_session
return TestClient(app)
backend/tests/test_tasks.py¶
"""Tests for task API endpoints."""
class TestCreateTask:
"""POST /api/v1/tasks"""
def test_create_task(self, client):
response = client.post("/api/v1/tasks", json={"title": "Write tests"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Write tests"
assert data["status"] == "pending"
assert data["priority"] == "medium"
def test_create_task_with_priority(self, client):
response = client.post(
"/api/v1/tasks",
json={"title": "Deploy", "priority": "critical"},
)
assert response.status_code == 201
assert response.json()["priority"] == "critical"
def test_create_task_missing_title(self, client):
response = client.post("/api/v1/tasks", json={"description": "no title"})
assert response.status_code == 422
class TestListTasks:
"""GET /api/v1/tasks"""
def test_list_empty(self, client):
response = client.get("/api/v1/tasks")
assert response.status_code == 200
assert response.json()["tasks"] == []
def test_list_with_tasks(self, client):
client.post("/api/v1/tasks", json={"title": "A"})
client.post("/api/v1/tasks", json={"title": "B"})
response = client.get("/api/v1/tasks")
assert response.json()["total"] == 2
class TestGetTask:
"""GET /api/v1/tasks/<id>"""
def test_get_existing(self, client):
create = client.post("/api/v1/tasks", json={"title": "Fetch me"})
task_id = create.json()["id"]
response = client.get(f"/api/v1/tasks/{task_id}")
assert response.status_code == 200
assert response.json()["title"] == "Fetch me"
def test_get_not_found(self, client):
response = client.get("/api/v1/tasks/9999")
assert response.status_code == 404
class TestUpdateTask:
"""PATCH /api/v1/tasks/<id>"""
def test_update_status(self, client):
create = client.post("/api/v1/tasks", json={"title": "Update me"})
task_id = create.json()["id"]
response = client.patch(
f"/api/v1/tasks/{task_id}", json={"status": "completed"}
)
assert response.status_code == 200
assert response.json()["status"] == "completed"
class TestDeleteTask:
"""DELETE /api/v1/tasks/<id>"""
def test_delete_task(self, client):
create = client.post("/api/v1/tasks", json={"title": "Delete me"})
task_id = create.json()["id"]
response = client.delete(f"/api/v1/tasks/{task_id}")
assert response.status_code == 204
backend/Dockerfile¶
FROM python:3.12-slim AS builder
WORKDIR /build
COPY pyproject.toml .
RUN pip install --no-cache-dir .
FROM python:3.12-slim AS runtime
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/uvicorn
COPY src/ ./src/
USER app
EXPOSE 8000
CMD ["uvicorn", "acme_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
frontend/package.json¶
{
"name": "acme-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"lint": "eslint src/ tests/",
"format:check": "prettier --check 'src/**/*.{ts,tsx}'"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^9.17.0",
"jsdom": "^25.0.0",
"prettier": "^3.4.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.18.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}
frontend/tsconfig.json¶
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src"]
}
frontend/vite.config.ts¶
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
"/api": { target: "http://localhost:8000", changeOrigin: true },
"/health": { target: "http://localhost:8000", changeOrigin: true },
},
},
});
frontend/src/types/index.ts¶
export interface Task {
readonly id: number;
title: string;
description: string | null;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority: "low" | "medium" | "high" | "critical";
readonly created_at: string;
readonly updated_at: string;
}
export interface PaginatedTasks {
tasks: Task[];
total: number;
page: number;
pages: number;
}
frontend/src/services/api.ts¶
import type { PaginatedTasks, Task } from "../types";
const BASE = "/api/v1";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}${path}`, {
headers: { "Content-Type": "application/json", ...init?.headers },
...init,
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return (await response.json()) as T;
}
export const fetchTasks = (): Promise<PaginatedTasks> => request("/tasks");
export const createTask = (title: string): Promise<Task> =>
request("/tasks", { method: "POST", body: JSON.stringify({ title }) });
export const deleteTask = (id: number): Promise<void> =>
request(`/tasks/${id}`, { method: "DELETE" });
frontend/src/App.tsx¶
import { TaskList } from "./components/TaskList";
export function App(): React.JSX.Element {
return (
<main>
<h1>ACME Platform</h1>
<TaskList />
</main>
);
}
frontend/src/components/TaskList.tsx¶
import { useCallback, useEffect, useState } from "react";
import type { Task } from "../types";
import * as api from "../services/api";
export function TaskList(): React.JSX.Element {
const [tasks, setTasks] = useState<Task[]>([]);
const [title, setTitle] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async (): Promise<void> => {
setLoading(true);
try {
const data = await api.fetchTasks();
setTasks(data.tasks);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const handleAdd = async (): Promise<void> => {
if (!title.trim()) return;
await api.createTask(title.trim());
setTitle("");
await refresh();
};
const handleDelete = async (id: number): Promise<void> => {
await api.deleteTask(id);
await refresh();
};
if (error) return <p role="alert">Error: {error}</p>;
return (
<section>
<form
onSubmit={(e) => {
e.preventDefault();
void handleAdd();
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="New task" />
<button type="submit">Add</button>
</form>
{loading ? (
<p>Loading...</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task.id}>
{task.title} ({task.status})
<button onClick={() => void handleDelete(task.id)}>Delete</button>
</li>
))}
</ul>
)}
</section>
);
}
frontend/tests/TaskList.test.tsx¶
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TaskList } from "../src/components/TaskList";
import * as api from "../src/services/api";
vi.mock("../src/services/api");
const mockedApi = vi.mocked(api);
describe("TaskList", () => {
it("shows loading state", () => {
mockedApi.fetchTasks.mockReturnValue(new Promise(() => {}));
render(<TaskList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders tasks after loading", async () => {
mockedApi.fetchTasks.mockResolvedValue({
tasks: [
{
id: 1,
title: "Task A",
description: null,
status: "pending",
priority: "medium",
created_at: "2025-01-15T00:00:00Z",
updated_at: "2025-01-15T00:00:00Z",
},
],
total: 1,
page: 1,
pages: 1,
});
render(<TaskList />);
await waitFor(() => expect(screen.getByText(/task a/i)).toBeInTheDocument());
});
});
frontend/Dockerfile¶
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n location / { try_files $uri /index.html; }\n location /api { proxy_pass http://backend:8000; }\n location /health { proxy_pass http://backend:8000; }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
infra/versions.tf¶
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
infra/variables.tf¶
variable "environment" {
description = "Deployment environment (dev, staging, prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "acme-platform"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "VPC CIDR must be a valid IPv4 CIDR block."
}
}
variable "db_instance_class" {
description = "RDS instance class"
type = string
default = "db.t3.micro"
}
variable "db_allocated_storage" {
description = "RDS allocated storage in GB"
type = number
default = 20
}
variable "container_port" {
description = "Port the backend container listens on"
type = number
default = 8000
}
variable "tags" {
description = "Additional tags for all resources"
type = map(string)
default = {}
}
infra/main.tf¶
provider "aws" {
region = "us-east-1"
default_tags {
tags = merge(
{
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
},
var.tags
)
}
}
# --- Networking ---
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.project_name}-${var.environment}-vpc" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.project_name}-${var.environment}-public-${count.index}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "${var.project_name}-${var.environment}-private-${count.index}" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project_name}-${var.environment}-igw" }
}
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}-${var.environment}-public-rt" }
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# --- Database ---
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-${var.environment}"
subnet_ids = aws_subnet.private[*].id
tags = { Name = "${var.project_name}-${var.environment}-db-subnet" }
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-${var.environment}"
engine = "postgres"
engine_version = "16"
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
storage_encrypted = true
db_name = "acme"
username = "acme_admin"
password = "CHANGE_ME_USE_SECRETS_MANAGER"
db_subnet_group_name = aws_db_subnet_group.main.name
multi_az = var.environment == "prod" ? true : false
skip_final_snapshot = var.environment != "prod"
tags = { Name = "${var.project_name}-${var.environment}-db" }
}
# --- ECS Cluster ---
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-${var.environment}"
setting {
name = "containerInsights"
value = "enabled"
}
tags = { Name = "${var.project_name}-${var.environment}-cluster" }
}
infra/outputs.tf¶
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of private subnets"
value = aws_subnet.private[*].id
}
output "db_endpoint" {
description = "RDS instance endpoint"
value = aws_db_instance.main.endpoint
}
output "ecs_cluster_arn" {
description = "ARN of the ECS cluster"
value = aws_ecs_cluster.main.arn
}
infra/environments/dev.tfvars¶
environment = "dev"
vpc_cidr = "10.0.0.0/16"
db_instance_class = "db.t3.micro"
db_allocated_storage = 20
tags = {
CostCenter = "engineering"
}
infra/environments/prod.tfvars¶
environment = "prod"
vpc_cidr = "10.1.0.0/16"
db_instance_class = "db.r6g.large"
db_allocated_storage = 100
tags = {
CostCenter = "production"
Compliance = "soc2"
}
infra/CONTRACT.md¶
# Infrastructure Contract: acme-platform
## Purpose
Provisions the core infrastructure for the ACME task management platform including
networking, database, and container orchestration resources on AWS.
## Guarantees
- **G1**: Creates exactly 1 VPC with DNS hostnames and DNS support enabled
- **G2**: Creates 2 public subnets distributed across 2 availability zones
- **G3**: Creates 2 private subnets in separate availability zones for database isolation
- **G4**: RDS instance uses encrypted storage by default
- **G5**: Production RDS is deployed in Multi-AZ configuration
- **G6**: ECS cluster has Container Insights enabled for observability
- **G7**: All resources are tagged with Project, Environment, and ManagedBy
- **G8**: Non-production environments skip final DB snapshot for faster teardown
## Inputs
| Variable | Type | Required | Default | Validation |
|-----------------------|-------------|----------|-----------------|--------------------------|
| environment | string | yes | - | dev, staging, prod |
| project_name | string | no | acme-platform | - |
| vpc_cidr | string | no | 10.0.0.0/16 | Valid IPv4 CIDR |
| db_instance_class | string | no | db.t3.micro | - |
| db_allocated_storage | number | no | 20 | - |
| container_port | number | no | 8000 | - |
| tags | map(string) | no | {} | - |
## Outputs
| Output | Description |
|--------------------|----------------------------|
| vpc_id | ID of the VPC |
| public_subnet_ids | IDs of public subnets |
| private_subnet_ids | IDs of private subnets |
| db_endpoint | RDS instance endpoint |
| ecs_cluster_arn | ARN of the ECS cluster |
## Platform Requirements
| Requirement | Value |
|--------------------|------------------|
| Terraform | >= 1.9 |
| AWS Provider | ~> 5.0 |
| AWS Region | us-east-1 |
## Breaking Changes Policy
- Major version bumps may rename outputs or change resource types
- Minor versions add resources; existing outputs are preserved
- Patch versions fix bugs without structural changes
- 30-day deprecation notice before removing any output
## Test Mapping
| Test | Guarantees Covered |
|-------------------------|-------------------|
| test_vpc_creation | G1, G2, G3 |
| test_rds_encryption | G4, G5 |
| test_ecs_cluster | G6 |
| test_resource_tagging | G7 |
| test_non_prod_snapshot | G8 |
.github/workflows/ci.yml¶
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Lint
run: |
black --check .
flake8 src/ tests/
- name: Type check
run: mypy src/
- name: Test
run: pytest
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build
infrastructure:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
- name: Format check
run: terraform fmt -check -recursive
- name: Initialize
run: terraform init -backend=false
- name: Validate
run: terraform validate
.github/workflows/deploy.yml¶
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
type: choice
options:
- dev
- prod
permissions:
contents: read
id-token: write
jobs:
deploy-backend:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Build and push backend image
run: |
echo "Building backend image for ${{ inputs.environment }}"
echo "docker build -t acme-api:${{ github.sha }} backend/"
- name: Deploy to ECS
run: |
echo "Deploying backend to ${{ inputs.environment }}"
echo "aws ecs update-service --cluster acme-platform-${{ inputs.environment }}"
deploy-frontend:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
needs: deploy-backend
steps:
- uses: actions/checkout@v4
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Deploy to S3/CloudFront
run: |
echo "Deploying frontend to ${{ inputs.environment }}"
echo "aws s3 sync frontend/dist/ s3://acme-platform-${{ inputs.environment }}/"
deploy-infrastructure:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
- name: Plan
working-directory: infra
run: |
terraform init
terraform plan -var-file=environments/${{ inputs.environment }}.tfvars -out=plan.tfplan
- name: Apply
if: inputs.environment == 'dev'
working-directory: infra
run: terraform apply plan.tfplan
Key Takeaways¶
This complete monorepo full-stack example demonstrates:
- Root Orchestration:
Makefileprovides unified targets for testing, linting, and running all services - Docker Compose Integration: Single
docker-compose.ymlruns the entire stack with health checks and dependencies - Multi-language Pre-commit: Single
.pre-commit-config.yamlruns Python linters onbackend/and Terraform formatters oninfra/ - Parallel CI Jobs: Backend, frontend, and infrastructure validate independently in parallel
- FastAPI + React: Modern Python backend with Pydantic schemas paired with TypeScript React frontend
- Shared Types: API contract enforced by matching Pydantic models and TypeScript interfaces
- Contract-Based Infrastructure:
CONTRACT.mdwith numbered guarantees (G1-G8) mapped to specific tests - Environment-Specific Configuration: Terraform
.tfvarsfiles for dev and prod with appropriate sizing - Staged Deployment: Deploy workflow targets individual environments with manual approval gates
- EditorConfig: Consistent formatting across Python (4 spaces), TypeScript (2 spaces), and Terraform (2 spaces)
The monorepo is production-ready and demonstrates how multiple technologies can coexist with independent validation pipelines and coordinated deployment.
Status: Active