commit 8dc74a1062cf23d3d9b2d469cfc1c45d301b95ec Author: Your Name Date: Wed Apr 1 13:27:54 2026 +0530 Task Manager Docker web application 3-service Docker app: Nginx frontend, Flask REST API backend, PostgreSQL database. Includes lifecycle scripts (prepare, start, stop, remove), docker-compose.yaml, and documentation. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74d5412 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +docs/superpowers/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e75b513 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# Task Manager - Docker Web Application + +A simple task manager web application deployed as a multi-container Docker system. Users can create, complete, and delete tasks through a web interface. + +## Prerequisites + +- **Linux** with Docker installed (Docker Engine 20.10+) +- **Docker Compose** v2 (optional, for `docker compose` deployment) +- Ports **80** must be available on the host machine + +## Application Description + +The Task Manager is a web-based CRUD application for managing personal tasks. It consists of three services working together: + +- A **frontend** web interface served by Nginx where users interact with the application +- A **backend** REST API built with Flask (Python) that handles business logic +- A **PostgreSQL database** that stores task data persistently + +Users can: +- Add new tasks +- Mark tasks as completed (toggle checkbox) +- Delete tasks +- View all tasks in a list sorted by creation date + +## Architecture + +``` +Browser (port 80) + | + v ++--------+ +-------+ +------------+ +| Nginx | ----> | Flask | ----> | PostgreSQL | +| :80 | API | :5000 | SQL | :5432 | ++--------+ proxy +-------+ +------------+ + static REST API persistent + files (gunicorn) volume +``` + +## Virtual Networks + +| Network Name | Driver | Purpose | +|-------------------|--------|------------------------------------------------------| +| taskapp-network | bridge | Connects all 3 containers so they can communicate | + +All containers are attached to `taskapp-network`. Only Nginx exposes a port (80) to the host. Flask and PostgreSQL are accessible only within the Docker network. + +## Named Volumes + +| Volume Name | Mount Point | Purpose | +|----------------|------------------------------------|----------------------------------| +| taskapp-pgdata | /var/lib/postgresql/data (in db) | Persists database data across container restarts and stops | + +Stopping and restarting the application preserves all task data thanks to this volume. + +## Containers + +### 1. taskapp-nginx (Frontend) + +- **Image:** Custom, built from `nginx:alpine` +- **Port:** 80 (host) -> 80 (container) +- **Role:** Serves static HTML/CSS/JS files and reverse-proxies `/api/*` requests to the Flask backend +- **Restart policy:** `unless-stopped` +- **Configuration:** Custom `nginx.conf` with `proxy_pass` directive for API routing + +### 2. taskapp-flask (Backend) + +- **Image:** Custom, built from `python:3.12-slim` +- **Port:** 5000 (internal only, not exposed to host) +- **Role:** REST API server handling task CRUD operations +- **Restart policy:** `unless-stopped` +- **Configuration:** Environment variables for database connection (`DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`) +- **WSGI server:** Gunicorn with 2 workers +- **Auto-initialization:** Creates the `tasks` table on startup if it does not exist + +### 3. taskapp-db (Database) + +- **Image:** `postgres:15` (from Docker Hub) +- **Port:** 5432 (internal only, not exposed to host) +- **Role:** Stores task data (id, title, completed status, creation timestamp) +- **Restart policy:** `unless-stopped` +- **Configuration:** Environment variables (`POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`) +- **Volume:** `taskapp-pgdata` mounted at `/var/lib/postgresql/data` + +## Container Configuration Details + +- **Nginx** is configured via `frontend/nginx.conf` which sets up static file serving and reverse proxy rules +- **Flask** reads database credentials from environment variables passed at container runtime +- **PostgreSQL** is configured via standard Postgres environment variables; data is stored on a named volume + +## Usage Instructions + +### Prepare the Application + +Build images and create Docker resources: + +```bash +./prepare-app.sh +``` + +### Start the Application + +Run all containers: + +```bash +./start-app.sh +``` + +Output: +``` +Starting app... +App is running! +The app is available at http://localhost:80 +``` + +### View in Web Browser + +Open your web browser and navigate to: + +``` +http://localhost:80 +``` + +You will see the Task Manager interface where you can add, complete, and delete tasks. + +### Stop the Application + +Stop all containers (data is preserved): + +```bash +./stop-app.sh +``` + +### Remove the Application + +Remove all containers, images, networks, and volumes: + +```bash +./remove-app.sh +``` + +### Alternative: Docker Compose + +You can also use Docker Compose instead of the shell scripts: + +```bash +# Start +docker compose up -d --build + +# Stop (preserves data) +docker compose down + +# Remove everything including volumes +docker compose down -v --rmi all +``` + +## API Endpoints + +| Method | Endpoint | Description | +|--------|-------------------|------------------------| +| GET | /api/tasks | List all tasks | +| POST | /api/tasks | Create a new task | +| PUT | /api/tasks/:id | Toggle task completion | +| DELETE | /api/tasks/:id | Delete a task | + +## Project Structure + +``` +. +├── backend/ +│ ├── Dockerfile # Python/Flask image definition +│ ├── requirements.txt # Python dependencies +│ └── app.py # Flask REST API application +├── frontend/ +│ ├── Dockerfile # Nginx image definition +│ ├── nginx.conf # Nginx configuration (static files + reverse proxy) +│ ├── index.html # Main HTML page +│ ├── style.css # Styles +│ └── app.js # Frontend JavaScript (fetch API calls) +├── docker-compose.yaml # Docker Compose configuration +├── prepare-app.sh # Script to build images and create resources +├── start-app.sh # Script to start all containers +├── stop-app.sh # Script to stop all containers +├── remove-app.sh # Script to remove all traces of the app +└── README.md # This file +``` + +## Sources + +- [Docker Documentation](https://docs.docker.com/) +- [Nginx Docker Image](https://hub.docker.com/_/nginx) +- [PostgreSQL Docker Image](https://hub.docker.com/_/postgres) +- [Python Docker Image](https://hub.docker.com/_/python) +- [Flask Documentation](https://flask.palletsprojects.com/) +- [Gunicorn Documentation](https://gunicorn.org/) +- [Nginx Reverse Proxy Guide](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) + +## Use of Artificial Intelligence + +This application was designed and implemented with the assistance of **Claude** (Anthropic), an AI assistant. Claude was used for: + +- Designing the application architecture and service composition +- Writing application source code (Python/Flask backend, HTML/CSS/JS frontend) +- Writing Dockerfiles and Docker Compose configuration +- Writing shell scripts for application lifecycle management +- Writing this documentation + +**AI agent used:** Claude Opus 4.6 (Anthropic) via Claude Code CLI diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4e55180 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5000 + +CMD ["sh", "-c", "python -c 'from app import init_db; init_db()' && gunicorn --bind 0.0.0.0:5000 --workers 2 app:app"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..8bf6059 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,110 @@ +import os +import time +import psycopg2 +from psycopg2.extras import RealDictCursor +from flask import Flask, request, jsonify + +app = Flask(__name__) + +DB_CONFIG = { + "host": os.environ.get("DB_HOST", "db"), + "database": os.environ.get("DB_NAME", "taskapp"), + "user": os.environ.get("DB_USER", "taskapp"), + "password": os.environ.get("DB_PASSWORD", "taskapp123"), +} + + +def get_db(): + return psycopg2.connect(**DB_CONFIG, cursor_factory=RealDictCursor) + + +def init_db(): + """Wait for PostgreSQL and create the tasks table if it doesn't exist.""" + for attempt in range(30): + try: + conn = get_db() + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + completed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + cur.close() + conn.close() + print("Database initialized.") + return + except psycopg2.OperationalError: + print(f"Waiting for database... (attempt {attempt + 1}/30)") + time.sleep(2) + raise RuntimeError("Could not connect to the database after 30 attempts.") + + +@app.route("/api/tasks", methods=["GET"]) +def get_tasks(): + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT * FROM tasks ORDER BY created_at DESC") + tasks = cur.fetchall() + cur.close() + conn.close() + return jsonify(tasks) + + +@app.route("/api/tasks", methods=["POST"]) +def create_task(): + data = request.get_json() + title = data.get("title", "").strip() + if not title: + return jsonify({"error": "Title is required"}), 400 + + conn = get_db() + cur = conn.cursor() + cur.execute( + "INSERT INTO tasks (title) VALUES (%s) RETURNING *", + (title,), + ) + task = cur.fetchone() + conn.commit() + cur.close() + conn.close() + return jsonify(task), 201 + + +@app.route("/api/tasks/", methods=["PUT"]) +def update_task(task_id): + conn = get_db() + cur = conn.cursor() + cur.execute( + "UPDATE tasks SET completed = NOT completed WHERE id = %s RETURNING *", + (task_id,), + ) + task = cur.fetchone() + conn.commit() + cur.close() + conn.close() + if task is None: + return jsonify({"error": "Task not found"}), 404 + return jsonify(task) + + +@app.route("/api/tasks/", methods=["DELETE"]) +def delete_task(task_id): + conn = get_db() + cur = conn.cursor() + cur.execute("DELETE FROM tasks WHERE id = %s RETURNING id", (task_id,)) + deleted = cur.fetchone() + conn.commit() + cur.close() + conn.close() + if deleted is None: + return jsonify({"error": "Task not found"}), 404 + return jsonify({"result": "ok"}) + + +if __name__ == "__main__": + init_db() + app.run(host="0.0.0.0", port=5000) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e223192 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.1 +psycopg2-binary==2.9.10 +gunicorn==23.0.0 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..cbfcd4a --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,47 @@ +services: + db: + image: postgres:15 + container_name: taskapp-db + restart: unless-stopped + environment: + POSTGRES_DB: taskapp + POSTGRES_USER: taskapp + POSTGRES_PASSWORD: taskapp123 + volumes: + - taskapp-pgdata:/var/lib/postgresql/data + networks: + - taskapp-network + + flask: + build: ./backend + image: taskapp-backend + container_name: taskapp-flask + restart: unless-stopped + environment: + DB_HOST: db + DB_NAME: taskapp + DB_USER: taskapp + DB_PASSWORD: taskapp123 + depends_on: + - db + networks: + - taskapp-network + + nginx: + build: ./frontend + image: taskapp-frontend + container_name: taskapp-nginx + restart: unless-stopped + ports: + - "80:80" + depends_on: + - flask + networks: + - taskapp-network + +volumes: + taskapp-pgdata: + +networks: + taskapp-network: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3cdaa5f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,8 @@ +FROM nginx:alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html /usr/share/nginx/html/ +COPY style.css /usr/share/nginx/html/ +COPY app.js /usr/share/nginx/html/ + +EXPOSE 80 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..afc2ce3 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,72 @@ +const taskList = document.getElementById("task-list"); +const taskForm = document.getElementById("task-form"); +const taskInput = document.getElementById("task-input"); +const emptyMsg = document.getElementById("empty-msg"); + +async function fetchTasks() { + const res = await fetch("/api/tasks"); + const tasks = await res.json(); + renderTasks(tasks); +} + +function renderTasks(tasks) { + taskList.innerHTML = ""; + emptyMsg.style.display = tasks.length === 0 ? "block" : "none"; + + tasks.forEach(function (task) { + const li = document.createElement("li"); + li.className = "task-item" + (task.completed ? " completed" : ""); + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "task-checkbox"; + checkbox.checked = task.completed; + checkbox.addEventListener("change", function () { + toggleTask(task.id); + }); + + const title = document.createElement("span"); + title.className = "task-title"; + title.textContent = task.title; + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "task-delete"; + deleteBtn.textContent = "\u00D7"; + deleteBtn.title = "Delete task"; + deleteBtn.addEventListener("click", function () { + deleteTask(task.id); + }); + + li.appendChild(checkbox); + li.appendChild(title); + li.appendChild(deleteBtn); + taskList.appendChild(li); + }); +} + +taskForm.addEventListener("submit", async function (e) { + e.preventDefault(); + const title = taskInput.value.trim(); + if (!title) return; + + await fetch("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: title }), + }); + + taskInput.value = ""; + fetchTasks(); +}); + +async function toggleTask(id) { + await fetch("/api/tasks/" + id, { method: "PUT" }); + fetchTasks(); +} + +async function deleteTask(id) { + await fetch("/api/tasks/" + id, { method: "DELETE" }); + fetchTasks(); +} + +fetchTasks(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..118cbb8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + Task Manager + + + +
+

Task Manager

+
+ + +
+
    +

    No tasks yet. Add one above!

    +
    + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..81069fb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name localhost; + + large_client_header_buffers 4 32k; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://flask:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..2bb64a3 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,120 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f0f2f5; + color: #1a1a2e; + min-height: 100vh; + display: flex; + justify-content: center; + padding-top: 60px; +} + +.container { + width: 100%; + max-width: 520px; + padding: 0 16px; +} + +h1 { + font-size: 28px; + font-weight: 700; + margin-bottom: 24px; + text-align: center; +} + +#task-form { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +#task-input { + flex: 1; + padding: 12px 16px; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 16px; + outline: none; + transition: border-color 0.2s; +} + +#task-input:focus { + border-color: #4a6cf7; +} + +#task-form button { + padding: 12px 24px; + background: #4a6cf7; + color: #fff; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +#task-form button:hover { + background: #3a5ce5; +} + +#task-list { + list-style: none; +} + +.task-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: #fff; + border-radius: 8px; + margin-bottom: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + transition: opacity 0.2s; +} + +.task-item.completed .task-title { + text-decoration: line-through; + opacity: 0.5; +} + +.task-checkbox { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: #4a6cf7; +} + +.task-title { + flex: 1; + font-size: 16px; +} + +.task-delete { + background: none; + border: none; + color: #e74c3c; + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + opacity: 0.6; + transition: opacity 0.2s; +} + +.task-delete:hover { + opacity: 1; +} + +.empty { + text-align: center; + color: #888; + font-size: 14px; + margin-top: 16px; +} diff --git a/prepare-app.sh b/prepare-app.sh new file mode 100644 index 0000000..b306ea3 --- /dev/null +++ b/prepare-app.sh @@ -0,0 +1,19 @@ +#!/bin/bash +echo "Preparing app..." + +# Build Docker images +echo "Building backend image..." +docker build -t taskapp-backend ./backend + +echo "Building frontend image..." +docker build -t taskapp-frontend ./frontend + +# Create network (ignore error if it already exists) +echo "Creating network..." +docker network create taskapp-network 2>/dev/null || true + +# Create named volume (ignore error if it already exists) +echo "Creating volume..." +docker volume create taskapp-pgdata 2>/dev/null || true + +echo "App prepared successfully." diff --git a/remove-app.sh b/remove-app.sh new file mode 100644 index 0000000..da3774d --- /dev/null +++ b/remove-app.sh @@ -0,0 +1,17 @@ +#!/bin/bash +echo "Removing app..." + +# Stop and remove containers +docker stop taskapp-nginx taskapp-flask taskapp-db 2>/dev/null +docker rm taskapp-nginx taskapp-flask taskapp-db 2>/dev/null + +# Remove images +docker rmi taskapp-backend taskapp-frontend 2>/dev/null + +# Remove network +docker network rm taskapp-network 2>/dev/null + +# Remove volume +docker volume rm taskapp-pgdata 2>/dev/null + +echo "App removed." diff --git a/start-app.sh b/start-app.sh new file mode 100644 index 0000000..49b6bb1 --- /dev/null +++ b/start-app.sh @@ -0,0 +1,39 @@ +#!/bin/bash +echo "Starting app..." + +# Start PostgreSQL +echo "Starting database..." +docker run -d \ + --name taskapp-db \ + --network taskapp-network \ + --restart unless-stopped \ + -e POSTGRES_DB=taskapp \ + -e POSTGRES_USER=taskapp \ + -e POSTGRES_PASSWORD=taskapp123 \ + -v taskapp-pgdata:/var/lib/postgresql/data \ + postgres:15 + +# Start Flask backend +echo "Starting backend..." +docker run -d \ + --name taskapp-flask \ + --network taskapp-network \ + --restart unless-stopped \ + -e DB_HOST=taskapp-db \ + -e DB_NAME=taskapp \ + -e DB_USER=taskapp \ + -e DB_PASSWORD=taskapp123 \ + taskapp-backend + +# Start Nginx frontend +echo "Starting frontend..." +docker run -d \ + --name taskapp-nginx \ + --network taskapp-network \ + --restart unless-stopped \ + -p 80:80 \ + taskapp-frontend + +echo "" +echo "App is running!" +echo "The app is available at http://localhost:80" diff --git a/stop-app.sh b/stop-app.sh new file mode 100644 index 0000000..87a8eb1 --- /dev/null +++ b/stop-app.sh @@ -0,0 +1,7 @@ +#!/bin/bash +echo "Stopping app..." + +docker stop taskapp-nginx taskapp-flask taskapp-db 2>/dev/null +docker rm taskapp-nginx taskapp-flask taskapp-db 2>/dev/null + +echo "App stopped."