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
This commit is contained in:
Ada
2026-04-06 09:04:24 -04:00
parent c283e591a4
commit c995161fa6
7 changed files with 270 additions and 0 deletions

4
deploy/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.git
.gitignore

17
deploy/Dockerfile Normal file
View File

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

74
deploy/README.md Normal file
View File

@@ -0,0 +1,74 @@
# Project Tracker - Docker Compose Deployment
## Quick Start
```bash
cd deploy
docker compose up -d
```
App available at `http://<your-server-ip>`
## 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.

36
deploy/docker-compose.yml Normal file
View File

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

22
deploy/nginx.conf Normal file
View File

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

14
deploy/package.json Normal file
View File

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

103
deploy/server.js Normal file
View File

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