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:
4
deploy/.dockerignore
Normal file
4
deploy/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
17
deploy/Dockerfile
Normal file
17
deploy/Dockerfile
Normal 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
74
deploy/README.md
Normal 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
36
deploy/docker-compose.yml
Normal 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
22
deploy/nginx.conf
Normal 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
14
deploy/package.json
Normal 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
103
deploy/server.js
Normal 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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user