Initial commit
This commit is contained in:
commit
b9bb60f17e
107
README.md
Normal file
107
README.md
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# Notes App — Docker Assignment
|
||||||
|
|
||||||
|
## What the application does
|
||||||
|
|
||||||
|
A simple **Notes** web application where users can create, view, and delete short text notes. Notes are stored persistently in a PostgreSQL database. The app is accessible via a web browser.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Container | Image | Port | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app_frontend` | custom (Nginx) | `8080→80` | Serves the static HTML/JS frontend and proxies `/api/` requests to the backend |
|
||||||
|
| `app_backend` | custom (Flask/Python) | internal `5000` | REST API for CRUD operations on notes |
|
||||||
|
| `app_db` | `postgres:16-alpine` | internal `5432` | Relational database storing notes persistently |
|
||||||
|
| `app_adminer` | `adminer:4` | `8081→8080` | Web UI for browsing and managing the PostgreSQL database |
|
||||||
|
|
||||||
|
## Networks
|
||||||
|
|
||||||
|
| Network | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `frontend_net` | Connects the Nginx frontend container (externally reachable) |
|
||||||
|
| `backend_net` | Internal network connecting frontend→backend→database and Adminer→database |
|
||||||
|
|
||||||
|
The database is only on `backend_net` and is never directly exposed to the host.
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
| Volume | Used by | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `postgres_data` | `app_db` | Persists PostgreSQL data across container restarts and stops |
|
||||||
|
|
||||||
|
## Container configuration
|
||||||
|
|
||||||
|
- **app_db**: configured via environment variables (`POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`). The `db/init.sql` file is mounted read-only into `/docker-entrypoint-initdb.d/` and runs once on first startup to create the `notes` table and insert seed data.
|
||||||
|
- **app_backend**: receives `DATABASE_URL` as an environment variable pointing to the `db` service. Built from `./backend/Dockerfile` using Python 3.12-slim with Flask and psycopg2.
|
||||||
|
- **app_frontend**: built from `./frontend/Dockerfile` using Nginx 1.27-alpine. The custom `nginx.conf` proxies all `/api/` requests to `app_backend:5000` and serves `index.html` for all other paths.
|
||||||
|
- **app_adminer**: uses the official Adminer image with no extra configuration. Reachable on port `8081`.
|
||||||
|
- All containers use `restart: on-failure`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux OS
|
||||||
|
- Docker Engine ≥ 24 with the Compose plugin (`docker compose`)
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
### Prepare (build images)
|
||||||
|
```bash
|
||||||
|
./prepare-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start
|
||||||
|
```bash
|
||||||
|
./start-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop (data is preserved)
|
||||||
|
```bash
|
||||||
|
./stop-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove everything
|
||||||
|
```bash
|
||||||
|
./remove-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Viewing the application
|
||||||
|
|
||||||
|
| URL | Description |
|
||||||
|
|---|---|
|
||||||
|
| http://localhost:8080 | Notes web application |
|
||||||
|
| http://localhost:8081 | Adminer — log in with Server: `db`, User: `appuser`, Password: `apppassword`, Database: `appdb` |
|
||||||
|
|
||||||
|
## Example workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./prepare-app.sh
|
||||||
|
# Preparing app...
|
||||||
|
|
||||||
|
./start-app.sh
|
||||||
|
# Running app...
|
||||||
|
# The app is available at http://localhost:8080
|
||||||
|
# Adminer (DB UI) is available at http://localhost:8081
|
||||||
|
|
||||||
|
# Open browser, add/delete notes
|
||||||
|
|
||||||
|
./stop-app.sh
|
||||||
|
# Stopping app...
|
||||||
|
# App stopped. Data in volumes is preserved.
|
||||||
|
|
||||||
|
./start-app.sh
|
||||||
|
# Notes are still there — volume was preserved
|
||||||
|
|
||||||
|
./remove-app.sh
|
||||||
|
# Removing app.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources used
|
||||||
|
|
||||||
|
- [Docker documentation](https://docs.docker.com/)
|
||||||
|
- [Flask documentation](https://flask.palletsprojects.com/)
|
||||||
|
- [Nginx documentation](https://nginx.org/en/docs/)
|
||||||
|
- [PostgreSQL Docker image](https://hub.docker.com/_/postgres)
|
||||||
|
- [Adminer Docker image](https://hub.docker.com/_/adminer)
|
||||||
|
|
||||||
|
## Use of artificial intelligence
|
||||||
|
|
||||||
|
This project was created with the assistance of **Kiro AI** (kiro-cli chat agent). The AI generated the initial structure of all files including the Docker Compose configuration, Flask application, Nginx configuration, frontend HTML/JS, and shell scripts. All generated code was reviewed and understood by the author. The AI was used as a coding assistant, not as a replacement for understanding the solution.
|
||||||
11
backend/Dockerfile
Normal file
11
backend/Dockerfile
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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 ["python", "app.py"]
|
||||||
37
backend/app.py
Normal file
37
backend/app.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from flask import Flask, jsonify, request, abort
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
def get_conn():
|
||||||
|
return psycopg2.connect(os.environ["DATABASE_URL"])
|
||||||
|
|
||||||
|
@app.route("/api/notes", methods=["GET"])
|
||||||
|
def get_notes():
|
||||||
|
with get_conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, content, created_at FROM notes ORDER BY created_at DESC")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify([{"id": r[0], "content": r[1], "created_at": r[2].isoformat()} for r in rows])
|
||||||
|
|
||||||
|
@app.route("/api/notes", methods=["POST"])
|
||||||
|
def add_note():
|
||||||
|
data = request.get_json()
|
||||||
|
content = (data or {}).get("content", "").strip()
|
||||||
|
if not content:
|
||||||
|
abort(400, "content required")
|
||||||
|
with get_conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("INSERT INTO notes (content) VALUES (%s) RETURNING id, content, created_at", (content,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"id": row[0], "content": row[1], "created_at": row[2].isoformat()}), 201
|
||||||
|
|
||||||
|
@app.route("/api/notes/<int:note_id>", methods=["DELETE"])
|
||||||
|
def delete_note(note_id):
|
||||||
|
with get_conn() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM notes WHERE id = %s", (note_id,))
|
||||||
|
conn.commit()
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000)
|
||||||
2
backend/requirements.txt
Normal file
2
backend/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
flask==3.0.3
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
9
db/init.sql
Normal file
9
db/init.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO notes (content) VALUES
|
||||||
|
('Welcome to the Notes App!'),
|
||||||
|
('Add, view, and delete notes using the web interface.');
|
||||||
55
docker-compose.yaml
Normal file
55
docker-compose.yaml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
frontend_net:
|
||||||
|
backend_net:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: app_db
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: appdb
|
||||||
|
POSTGRES_USER: appuser
|
||||||
|
POSTGRES_PASSWORD: apppassword
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
networks:
|
||||||
|
- backend_net
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: app_backend
|
||||||
|
restart: on-failure
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://appuser:apppassword@db:5432/appdb
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- backend_net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: app_frontend
|
||||||
|
restart: on-failure
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- frontend_net
|
||||||
|
- backend_net
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer:4
|
||||||
|
container_name: app_adminer
|
||||||
|
restart: on-failure
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
networks:
|
||||||
|
- backend_net
|
||||||
6
frontend/Dockerfile
Normal file
6
frontend/Dockerfile
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
65
frontend/index.html
Normal file
65
frontend/index.html
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Notes App</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 16px; }
|
||||||
|
h1 { color: #333; }
|
||||||
|
textarea { width: 100%; height: 80px; box-sizing: border-box; }
|
||||||
|
button { margin-top: 8px; padding: 8px 16px; cursor: pointer; }
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
li { display: flex; justify-content: space-between; align-items: flex-start;
|
||||||
|
border-bottom: 1px solid #eee; padding: 10px 0; }
|
||||||
|
li span { flex: 1; white-space: pre-wrap; }
|
||||||
|
li small { color: #999; font-size: 0.8em; display: block; }
|
||||||
|
.del { background: #e55; color: #fff; border: none; border-radius: 4px;
|
||||||
|
padding: 4px 10px; cursor: pointer; margin-left: 12px; }
|
||||||
|
#status { color: red; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📝 Notes App</h1>
|
||||||
|
<textarea id="content" placeholder="Write a note..."></textarea><br/>
|
||||||
|
<button onclick="addNote()">Add Note</button>
|
||||||
|
<p id="status"></p>
|
||||||
|
<ul id="notes"></ul>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function loadNotes() {
|
||||||
|
const res = await fetch('/api/notes');
|
||||||
|
const notes = await res.json();
|
||||||
|
const ul = document.getElementById('notes');
|
||||||
|
ul.innerHTML = notes.map(n => `
|
||||||
|
<li>
|
||||||
|
<div><span>${escHtml(n.content)}</span><small>${n.created_at}</small></div>
|
||||||
|
<button class="del" onclick="deleteNote(${n.id})">✕</button>
|
||||||
|
</li>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNote() {
|
||||||
|
const ta = document.getElementById('content');
|
||||||
|
const content = ta.value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
const res = await fetch('/api/notes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({content})
|
||||||
|
});
|
||||||
|
if (res.ok) { ta.value = ''; loadNotes(); }
|
||||||
|
else document.getElementById('status').textContent = 'Error adding note.';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote(id) {
|
||||||
|
await fetch('/api/notes/' + id, {method: 'DELETE'});
|
||||||
|
loadNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNotes();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
frontend/nginx.conf
Normal file
14
frontend/nginx.conf
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
prepare-app.sh
Executable file
8
prepare-app.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Preparing app..."
|
||||||
|
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
echo "Done. Run ./start-app.sh to start the application."
|
||||||
8
remove-app.sh
Executable file
8
remove-app.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Removing app..."
|
||||||
|
|
||||||
|
docker compose down --volumes --rmi local
|
||||||
|
|
||||||
|
echo "Removed app."
|
||||||
11
start-app.sh
Executable file
11
start-app.sh
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Running app..."
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "The app is available at http://localhost:8080"
|
||||||
|
echo "Adminer (DB UI) is available at http://localhost:8081"
|
||||||
|
echo " Server: db | User: appuser | Password: apppassword | Database: appdb"
|
||||||
8
stop-app.sh
Executable file
8
stop-app.sh
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Stopping app..."
|
||||||
|
|
||||||
|
docker compose stop
|
||||||
|
|
||||||
|
echo "App stopped. Data in volumes is preserved."
|
||||||
Loading…
Reference in New Issue
Block a user