From c995161fa6abb95b42ec12972702a6ae255f21a6 Mon Sep 17 00:00:00 2001 From: Ada Date: Mon, 6 Apr 2026 09:04:24 -0400 Subject: [PATCH] Add Docker Compose deployment - Dockerfile: Node.js 20 Alpine, single-stage build - docker-compose.yml: backend + nginx, named volume for SQLite - server.js: updated for Docker (env vars for PORT/DATA_DIR) - nginx.conf: updated for Docker networking (backend hostname) - README.md: full deployment instructions --- deploy/.dockerignore | 4 ++ deploy/Dockerfile | 17 +++++++ deploy/README.md | 74 +++++++++++++++++++++++++++ deploy/docker-compose.yml | 36 +++++++++++++ deploy/nginx.conf | 22 ++++++++ deploy/package.json | 14 ++++++ deploy/server.js | 103 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 270 insertions(+) create mode 100644 deploy/.dockerignore create mode 100644 deploy/Dockerfile create mode 100644 deploy/README.md create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/nginx.conf create mode 100644 deploy/package.json create mode 100644 deploy/server.js diff --git a/deploy/.dockerignore b/deploy/.dockerignore new file mode 100644 index 0000000..b6b1719 --- /dev/null +++ b/deploy/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.git +.gitignore diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..e7ffe81 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY server.js ./ + +# Create data directory for SQLite +RUN mkdir -p /app/data + +VOLUME ["/app/data"] + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..095013d --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,74 @@ +# Project Tracker - Docker Compose Deployment + +## Quick Start + +```bash +cd deploy +docker compose up -d +``` + +App available at `http://` + +## First Run Notes + +- The SQLite database initializes automatically on first start +- The database file is persisted in a Docker named volume (`project-tracker-data`) +- To seed with sample projects, exec into the container: + ```bash + docker exec -it project-tracker-api node -e " + const db = require('better-sqlite3')('/app/data/projects.db'); + const items = [ + {name:'Bastion setup with Headscale/Tailscale',priority:'Medium',url:'',notes:'Use i3-4130T as hardened jump-box/gateway into homelab.',status:'Active',owner:'Ada',tags:'vpn,homelab,security'}, + {name:'Outline',priority:'Med-High',url:'https://www.getoutline.com/',notes:'Self-hosted knowledge base/wiki (Notion alternative).',status:'Backlog',owner:'Ada',tags:'wiki,notes'}, + {name:'Tinyauth',priority:'Med-High',url:'https://tinyauth.app/',notes:'Self-hosted zero-trust authentication platform.',status:'Backlog',owner:'Ada',tags:'auth,security'} + ]; + const stmt = db.prepare('INSERT INTO projects (name,priority,url,notes,status,owner,tags) VALUES (?,?,?,?,?,?,?)'); + items.forEach(i => stmt.run(i.name,i.priority,i.url,i.notes,i.status,i.owner,i.tags)); + console.log('Seeded', items.length, 'projects'); + " + ``` + +## Commands + +```bash +docker compose up -d # Start +docker compose logs -f # View logs +docker compose restart # Restart +docker compose down # Stop +docker compose down -v # Stop and DELETE database volume (CAREFUL!) +docker exec -it project-tracker-api sh # Shell into backend container +``` + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `3000` | Backend port (internal) | +| `NODE_ENV` | `production` | Node environment | +| `DATA_DIR` | `/app/data` | SQLite data directory | + +## Ports + +| Port | Service | +|---|---| +| `80` | nginx (frontend + API proxy) | +| `3000` | Backend API (internal only, not exposed) | + +## Volumes + +| Volume | Mount | Description | +|---|---|---| +| `project-tracker-data` | `/app/data` | SQLite database file | + +## Updating + +```bash +cd deploy +docker compose down +docker compose build --no-cache +docker compose up -d +``` + +## SSL / HTTPS + +For HTTPS, put nginx behind a reverse proxy (e.g., NginxProxyManager, Traefik, Caddy) on port 80/443. The nginx container should NOT be exposed directly to the internet. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..7beef52 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,36 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile + container_name: project-tracker-api + restart: unless-stopped + volumes: + - project-data:/app/data + environment: + - PORT=3000 + - NODE_ENV=production + networks: + - project-tracker-net + + nginx: + image: nginx:alpine + container_name: project-tracker-nginx + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ../frontend:/usr/share/nginx/html:ro + depends_on: + - backend + networks: + - project-tracker-net + +volumes: + project-data: + name: project-tracker-data + +networks: + project-tracker-net: + name: project-tracker-net diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..fe1c4d3 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + + # Serve frontend static files + root /usr/share/nginx/html; + index index.html; + + # API proxy to Node backend + location /api/ { + proxy_pass http://backend:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Static files - SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/package.json b/deploy/package.json new file mode 100644 index 0000000..9d0fa4b --- /dev/null +++ b/deploy/package.json @@ -0,0 +1,14 @@ +{ + "name": "project-tracker-backend", + "version": "1.0.0", + "description": "Project Tracker API", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.18.2" + } +} diff --git a/deploy/server.js b/deploy/server.js new file mode 100644 index 0000000..b0b4cee --- /dev/null +++ b/deploy/server.js @@ -0,0 +1,103 @@ +const express = require('express'); +const sqlite3 = require('better-sqlite3'); +const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const DATA_DIR = process.env.DATA_DIR || '/app/data'; +const DB_PATH = path.join(DATA_DIR, 'projects.db'); + +// Ensure data directory exists +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +// Initialize database +const db = new sqlite3(DB_PATH); + +// Create table +db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + priority TEXT NOT NULL, + url TEXT DEFAULT '', + notes TEXT DEFAULT '', + status TEXT DEFAULT 'Backlog', + owner TEXT DEFAULT 'Ada', + tags TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) +`); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// GET all projects +app.get('/api/projects', (req, res) => { + const { status } = req.query; + let stmt = status + ? db.prepare('SELECT * FROM projects WHERE status = ? ORDER BY CASE priority WHEN \'High\' THEN 1 WHEN \'Med-High\' THEN 2 WHEN \'Medium\' THEN 3 WHEN \'Low\' THEN 4 ELSE 5 END, created_at DESC') + : db.prepare('SELECT * FROM projects ORDER BY CASE priority WHEN \'High\' THEN 1 WHEN \'Med-High\' THEN 2 WHEN \'Medium\' THEN 3 WHEN \'Low\' THEN 4 ELSE 5 END, created_at DESC'); + const rows = status ? stmt.all(status) : stmt.all(); + res.json(rows); +}); + +// GET single project +app.get('/api/projects/:id', (req, res) => { + const stmt = db.prepare('SELECT * FROM projects WHERE id = ?'); + const row = stmt.get(req.params.id); + if (row) res.json(row); + else res.status(404).json({ error: 'Not found' }); +}); + +// POST create project +app.post('/api/projects', (req, res) => { + const { name, priority, url, notes, status, owner, tags } = req.body; + if (!name || !priority) { + return res.status(400).json({ error: 'name and priority are required' }); + } + const stmt = db.prepare(` + INSERT INTO projects (name, priority, url, notes, status, owner, tags) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run(name, priority, url || '', notes || '', status || 'Backlog', owner || 'Ada', tags || ''); + res.json({ id: result.lastInsertRowid, message: 'Created' }); +}); + +// PUT update project +app.put('/api/projects/:id', (req, res) => { + const { name, priority, url, notes, status, owner, tags } = req.body; + const fields = []; + const values = []; + if (name !== undefined) { fields.push('name = ?'); values.push(name); } + if (priority !== undefined) { fields.push('priority = ?'); values.push(priority); } + if (url !== undefined) { fields.push('url = ?'); values.push(url); } + if (notes !== undefined) { fields.push('notes = ?'); values.push(notes); } + if (status !== undefined) { fields.push('status = ?'); values.push(status); } + if (owner !== undefined) { fields.push('owner = ?'); values.push(owner); } + if (tags !== undefined) { fields.push('tags = ?'); values.push(tags); } + fields.push('updated_at = datetime(\'now\')'); + values.push(req.params.id); + if (fields.length === 1) { + return res.status(400).json({ error: 'No fields to update' }); + } + const stmt = db.prepare(`UPDATE projects SET ${fields.join(', ')} WHERE id = ?`); + stmt.run(...values); + res.json({ message: 'Updated' }); +}); + +// DELETE project +app.delete('/api/projects/:id', (req, res) => { + const stmt = db.prepare('DELETE FROM projects WHERE id = ?'); + stmt.run(req.params.id); + res.json({ message: 'Deleted' }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`Project Tracker API running on port ${PORT}`); +});