Introduces scalable Docker Compose deployment

Establishes a multi-service Docker Compose setup for the application, including a Traefik reverse proxy, a MySQL database, and a scalable API service.

Adds a comprehensive MySQL database schema for bug reports, file storage, rate limiting, user management, and sessions.

Improves application startup reliability by implementing a "wait-for-MySQL" script, ensuring the API service initializes only after the database is available. This script is integrated into the Dockerfile and package.json.

Enhances the API service with instance-specific logging, a more informative health endpoint, and an increased maximum request body size (50MB). Database migration failures are now gracefully handled.
This commit is contained in:
Flavio Fois
2026-02-25 20:57:19 +01:00
parent 211ce5f1f6
commit 19e199a578
8 changed files with 178 additions and 10 deletions

View File

@@ -9,4 +9,5 @@ COPY src/ ./src/
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "run", "src/index.ts"] # Use a small startup script that waits for MySQL to be ready
CMD ["bun", "run", "src/wait-for-mysql.ts"]

View File

@@ -1,7 +1,42 @@
services: services:
api: reverse-proxy:
image: traefik:latest
command:
- "--api.insecure=true"
- "--providers.docker"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
labels:
- "traefik.enable=false"
emly-mysql-db:
image: mysql:lts
labels:
- "traefik.enable=false"
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_USER: ${MYSQL_USER:-emly}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-emly_bugreports}
volumes:
- mysql-data:/var/lib/mysql
- ./init:/docker-entrypoint-initdb.d
restart: unless-stopped
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
emly-api:
build: . build: .
environment: environment:
INSTANCE_ID: ${HOSTNAME}
MYSQL_HOST: ${MYSQL_HOST:-mysql} MYSQL_HOST: ${MYSQL_HOST:-mysql}
MYSQL_PORT: ${MYSQL_PORT:-3306} MYSQL_PORT: ${MYSQL_PORT:-3306}
MYSQL_USER: ${MYSQL_USER:-emly} MYSQL_USER: ${MYSQL_USER:-emly}
@@ -13,5 +48,26 @@ services:
RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5} RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-5}
RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24} RATE_LIMIT_WINDOW_HOURS: ${RATE_LIMIT_WINDOW_HOURS:-24}
volumes: volumes:
- ./logs/api:/app/logs - emly-api-logs:/app/logs
restart: on-failure restart: unless-stopped
deploy:
mode: replicated
replicas: 3
resources:
limits:
cpus: '1'
memory: 1024M
reservations:
cpus: '0.5'
memory: 512M
labels:
- "traefik.enable=true"
- "traefik.http.routers.emly-api.entrypoints=web"
- "traefik.http.routers.emly-api.rule=PathPrefix(`/`)"
- "traefik.http.services.emly-api.loadbalancer.server.port=3000"
depends_on:
- emly-mysql-db
volumes:
mysql-data:
emly-api-logs:

62
init/init.sql Normal file
View File

@@ -0,0 +1,62 @@
CREATE TABLE IF NOT EXISTS `bug_reports` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`hwid` VARCHAR(255) NOT NULL DEFAULT '',
`hostname` VARCHAR(255) NOT NULL DEFAULT '',
`os_user` VARCHAR(255) NOT NULL DEFAULT '',
`submitter_ip` VARCHAR(45) NOT NULL DEFAULT '',
`system_info` JSON NULL,
`status` ENUM('new', 'in_review', 'resolved', 'closed') NOT NULL DEFAULT 'new',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_status` (`status`),
INDEX `idx_hwid` (`hwid`),
INDEX `idx_hostname` (`hostname`),
INDEX `idx_os_user` (`os_user`),
INDEX `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `bug_report_files` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`report_id` INT UNSIGNED NOT NULL,
`file_role` ENUM('screenshot', 'mail_file', 'localstorage', 'config', 'system_info') NOT NULL,
`filename` VARCHAR(255) NOT NULL,
`mime_type` VARCHAR(127) NOT NULL DEFAULT 'application/octet-stream',
`file_size` INT UNSIGNED NOT NULL DEFAULT 0,
`data` LONGBLOB NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT `fk_report` FOREIGN KEY (`report_id`) REFERENCES `bug_reports`(`id`) ON DELETE CASCADE,
INDEX `idx_report_id` (`report_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `rate_limit_hwid` (
`hwid` VARCHAR(255) PRIMARY KEY,
`window_start` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`count` INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `user` (
`id` VARCHAR(255) PRIMARY KEY,
`username` VARCHAR(255) NOT NULL UNIQUE,
`password_hash` VARCHAR(255) NOT NULL,
`role` ENUM('admin', 'user') NOT NULL DEFAULT 'user',
`enabled` BOOLEAN NOT NULL DEFAULT TRUE,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`displayname` VARCHAR(255) NOT NULL DEFAULT ''
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `session` (
`id` VARCHAR(255) PRIMARY KEY,
`user_id` VARCHAR(255) NOT NULL,
`expires_at` DATETIME NOT NULL,
CONSTRAINT `fk_session_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER hwid;
ALTER TABLE bug_reports ADD COLUMN IF NOT EXISTS os_user VARCHAR(255) NOT NULL DEFAULT '' AFTER hostname;
ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_hostname (hostname);
ALTER TABLE bug_reports ADD INDEX IF NOT EXISTS idx_os_user (os_user);
ALTER TABLE user ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE AFTER role;

View File

@@ -4,7 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "bun run --watch src/index.ts", "dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts" "start": "bun run src/index.ts",
"start:wait": "bun run src/wait-for-mysql.ts"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",

View File

@@ -33,7 +33,7 @@ export async function runMigrations(): Promise<void> {
try { try {
await pool.execute(migration); await pool.execute(migration);
} catch { } catch {
// Column/index already exists safe to ignore // Column/index already exists - safe to ignore
} }
} }

View File

@@ -6,6 +6,8 @@ import { bugReportRoutes } from "./routes/bugReports";
import { adminRoutes } from "./routes/admin"; import { adminRoutes } from "./routes/admin";
import { initLogger, Log } from "./logger"; import { initLogger, Log } from "./logger";
const INSTANCE_ID = process.env.HOSTNAME + "_" + Math.random().toString(16).slice(2, 6);
// Initialize logger // Initialize logger
initLogger(); initLogger();
@@ -13,7 +15,12 @@ initLogger();
validateConfig(); validateConfig();
// Run database migrations // Run database migrations
try {
await runMigrations(); await runMigrations();
} catch (error) {
Log("ERROR", "Failed to run migrations:", error);
process.exit(1);
}
const app = new Elysia() const app = new Elysia()
.onRequest(({ request }) => { .onRequest(({ request }) => {
@@ -22,7 +29,7 @@ const app = new Elysia()
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") || request.headers.get("x-real-ip") ||
"unknown"; "unknown";
Log("HTTP", `${request.method} ${url.pathname} from ${ip}`); Log("HTTP", `[${INSTANCE_ID}] ${request.method} ${url.pathname} from ${ip}`);
}) })
.onAfterResponse(({ request, set }) => { .onAfterResponse(({ request, set }) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -33,11 +40,13 @@ const app = new Elysia()
set.status = 500; set.status = 500;
return { success: false, message: "Internal server error" }; return { success: false, message: "Internal server error" };
}) })
.get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() })) .get("/health", () => ({ status: "ok", instance: INSTANCE_ID, timestamp: new Date().toISOString() }))
.get("/", () => ({ status: "ok", message: "API is running" }))
.use(bugReportRoutes) .use(bugReportRoutes)
.use(adminRoutes) .use(adminRoutes)
.listen({ .listen({
port: config.port, port: config.port,
//@ts-ignore
maxBody: 50 * 1024 * 1024, // 50MB maxBody: 50 * 1024 * 1024, // 50MB
}); });

View File

@@ -28,7 +28,7 @@ export const bugReportRoutes = new Elysia({ prefix: "/api/bug-reports" })
Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`); Log("BUGREPORT", `Received from name=${name} hwid=${hwid || "none"} ip=${submitterIp}`);
// Parse system_info may arrive as a JSON string or already-parsed object // Parse system_info - may arrive as a JSON string or already-parsed object
let systemInfo: Record<string, unknown> | null = null; let systemInfo: Record<string, unknown> | null = null;
if (system_info) { if (system_info) {
if (typeof system_info === "string") { if (typeof system_info === "string") {

39
src/wait-for-mysql.ts Normal file
View File

@@ -0,0 +1,39 @@
import net from 'net'
import { spawn } from 'child_process'
const host = process.env.MYSQL_HOST || 'mysql'
const port = parseInt(process.env.MYSQL_PORT || '3306', 10)
const retryDelay = 2000
function waitForPort(host: string, port: number) {
return new Promise<void>((resolve) => {
const tryConnect = () => {
const socket = new net.Socket()
socket.setTimeout(2000)
socket.once('connect', () => {
socket.destroy()
resolve()
})
socket.once('error', () => {
socket.destroy()
setTimeout(tryConnect, retryDelay)
})
socket.once('timeout', () => {
socket.destroy()
setTimeout(tryConnect, retryDelay)
})
socket.connect(port, host)
}
tryConnect()
})
}
;(async () => {
console.log(`Waiting for MySQL at ${host}:${port}...`)
await waitForPort(host, port)
console.log('MySQL is available - starting API.')
const child = spawn('bun', ['run', 'src/index.ts'], { stdio: 'inherit' })
;['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((sig) =>
process.on(sig as NodeJS.Signals, () => child.kill(sig as NodeJS.Signals))
)
})()