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.
This commit is contained in:
Your Name 2026-04-01 13:27:54 +05:30
commit 8dc74a1062
15 changed files with 703 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
docs/superpowers/

207
README.md Normal file
View File

@ -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

12
backend/Dockerfile Normal file
View File

@ -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"]

110
backend/app.py Normal file
View File

@ -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/<int:task_id>", 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/<int:task_id>", 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)

3
backend/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask==3.1.1
psycopg2-binary==2.9.10
gunicorn==23.0.0

47
docker-compose.yaml Normal file
View File

@ -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

8
frontend/Dockerfile Normal file
View File

@ -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

72
frontend/app.js Normal file
View File

@ -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();

21
frontend/index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<h1>Task Manager</h1>
<form id="task-form">
<input type="text" id="task-input" placeholder="Enter a new task..." required>
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
<p id="empty-msg" class="empty">No tasks yet. Add one above!</p>
</div>
<script src="/app.js"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@ -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;
}
}

120
frontend/style.css Normal file
View File

@ -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;
}

19
prepare-app.sh Normal file
View File

@ -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."

17
remove-app.sh Normal file
View File

@ -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."

39
start-app.sh Normal file
View File

@ -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"

7
stop-app.sh Normal file
View File

@ -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."