diff --git a/.env.example b/.env.example index c79de53..aec68b7 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,19 @@ PORT=8000 NODE_ENV=development CORS_ORIGIN=* +# ---- Container image (Docker Compose only) ---- +# Pre-built multi-arch images are published to GHCR on every push to main. +# Pin a release with e.g. ghcr.io/metalfinger/refboard:v0.5.0 — see +# https://github.com/metalfinger/refboard/pkgs/container/refboard for tags. +REFBOARD_IMAGE=ghcr.io/metalfinger/refboard:latest + # ---- JWT auth ---- -# REQUIRED in production. Generate with: openssl rand -base64 64 -JWT_SECRET=change-me-to-a-long-random-string +# OPTIONAL. If unset, RefBoard generates a 64-byte random secret on first boot +# and persists it in the SQLite settings table. Set this env var only if you +# want ops to manage the secret out-of-band (e.g. via a secrets manager) — when +# set, it always overrides the persisted value. Rotate by clearing the env var +# *and* deleting the settings.jwt_secret row; this invalidates existing tokens. +# JWT_SECRET= JWT_EXPIRES_IN=7d # ---- SQLite database ---- @@ -18,7 +28,16 @@ JWT_EXPIRES_IN=7d # For local non-Docker dev, set this to ./data/refboard.db (relative to backend/). DB_PATH=/app/data/refboard.db -# ---- Object storage (MinIO / any S3-compatible) ---- +# ---- Object storage ---- +# STORAGE_BACKEND=minio (default) — uses MinIO or any S3-compatible store. +# STORAGE_BACKEND=fs — stores media bytes on the local filesystem +# under STORAGE_DATA_DIR. Collapses the stack +# to a single container; see +# examples/compose/minimal-fs.yml. +STORAGE_BACKEND=minio +STORAGE_DATA_DIR=/app/data/storage + +# MinIO settings (ignored when STORAGE_BACKEND=fs) MINIO_ENDPOINT=minio MINIO_PORT=9000 MINIO_USE_SSL=false diff --git a/README.md b/README.md index 2b14102..0a817f2 100644 --- a/README.md +++ b/README.md @@ -72,21 +72,36 @@ Requires Docker + Docker Compose v2. ```bash git clone https://github.com/metalfinger/refboard cd refboard +bash scripts/setup.sh # macOS / Linux +# or, on Windows PowerShell: +# .\scripts\setup.ps1 +``` + +The script copies `.env.example` → `.env`, pulls the pre-built multi-arch image from GHCR, brings the stack up, waits for the backend to report healthy, and opens the URL in your browser. Re-running it is safe. + +Prefer the raw commands? They're equivalent to: + +```bash cp .env.example .env +docker compose pull +docker compose up -d +``` -# Edit .env and at minimum set: -# JWT_SECRET= -# SEED_ADMIN_EMAIL=you@example.com -# SEED_ADMIN_PASSWORD= +Open , create the first admin account on the screen RefBoard shows you, and you're in. No env-var editing required to get started — `JWT_SECRET` is generated and persisted on first boot, and the first user you register is auto-promoted to admin. +For production / non-localhost installs, see the [going-public checklist](#checklist-when-going-public) below — you'll want to set `JWT_SECRET` explicitly, lock down `CORS_ORIGIN`, and turn off self-registration. + +To build from source instead of pulling (slower; needed only if you've modified the code): + +```bash docker compose up --build ``` -Then open and sign in with the seeded admin email / password. +The default image is `ghcr.io/metalfinger/refboard:latest`. Pin a specific version by setting `REFBOARD_IMAGE=ghcr.io/metalfinger/refboard:v0.5.0` in `.env`. MinIO console (S3 dashboard) is at — login is whatever you set as `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` in `.env` (defaults to `minioadmin` / `minioadmin`). -Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back this up. +Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back this up — that's also where the auto-generated `JWT_SECRET` lives, so losing it logs everyone out. --- @@ -117,11 +132,11 @@ docker run -d --name minio -p 9000:9000 -p 9001:9001 \ cd backend npm install DB_PATH=./data/refboard.db \ -JWT_SECRET=$(openssl rand -base64 64) \ MINIO_ENDPOINT=localhost \ -SEED_ADMIN_EMAIL=you@example.com \ -SEED_ADMIN_PASSWORD=changeme \ node server.js +# JWT_SECRET is auto-generated and persisted on first boot — set it explicitly +# only if you want to manage it via an external secrets manager. The first user +# you register in the browser becomes admin automatically. # 3. Frontend (dev mode, separate terminal) cd frontend @@ -155,6 +170,8 @@ All knobs live in `.env`. See [`.env.example`](.env.example) for the full annota RefBoard is just an HTTP server on port 8000 — every reverse-proxy / tunneling option works. The only non-obvious bit is that it uses Socket.IO over WebSockets, so whatever fronts it must allow WS upgrades. +Pre-baked deployment configs for the common patterns (Cloudflare Tunnel sidecar, Caddy auto-TLS, Fly.io, Render, single-container FS-storage) live in [`examples/`](examples/README.md). The hand-rolled instructions below are still valid; the examples just spare you the YAML. + ### Cloudflare Tunnel (zero open ports, free TLS, recommended for home / studio servers) This is what I run my own instance behind. No router config, no public IP, no Let's Encrypt — Cloudflare proxies the connection through an outbound tunnel from the box. @@ -243,8 +260,8 @@ Anyone in your tailnet can hit it; no one else can. ### Checklist when going public -- [ ] Set `JWT_SECRET` to a real random value (`openssl rand -base64 64`). -- [ ] Set `NODE_ENV=production` (the backend refuses to start in prod without `JWT_SECRET`). +- [ ] Set `JWT_SECRET` explicitly (`openssl rand -base64 64`). For production installs we recommend pinning the secret in `.env` rather than relying on the auto-generated one in the SQLite settings table — easier to rotate, easier to back up to a secrets manager. +- [ ] Set `NODE_ENV=production`. - [ ] Set `CORS_ORIGIN=https://your.domain` (drop the wildcard). - [ ] Confirm self-registration is **off** in the admin dashboard (defaults off; only flips on if you set `ALLOW_SELF_REGISTRATION=true` on first boot). - [ ] Restrict the MinIO console (port 9001) to localhost — only the S3 API on 9000 needs to be reachable from the backend, and the backend already proxies media bytes through `/api/images/*`, so MinIO does **not** need to be exposed publicly. @@ -289,8 +306,11 @@ See [CHANGELOG.md](CHANGELOG.md) for the version history (v0.1.0 → v0.5.0). - [x] Admin dashboard frontend (live at `/admin` — user create / reset-password / role / deactivate) - [x] Per-board activity log (uploads, board events, threads, comments — live via Socket.IO) -- [ ] **One-click `setup.sh` installer** for designers — see [`docs/install-roadmap.md`](docs/install-roadmap.md) -- [ ] **Native installer** (`.dmg` / `.exe`) with no Docker dependency — see [`docs/install-roadmap.md`](docs/install-roadmap.md) +- [x] **Pre-built multi-arch image** at `ghcr.io/metalfinger/refboard` (linux/amd64 + linux/arm64) — published on every push to main +- [x] **Zero-edit first boot** — `JWT_SECRET` auto-generated and persisted, first registered user is auto-admin +- [x] **FS storage adapter** — `STORAGE_BACKEND=fs` drops the MinIO dependency for single-container installs +- [x] **One-click installers** — `scripts/setup.sh` (macOS / Linux) and `scripts/setup.ps1` (Windows) +- [x] **PaaS deploy templates** — Fly.io, Render, Cloudflare Tunnel sidecar, Caddy auto-TLS in [`examples/`](examples/README.md) - [ ] Mobile-friendly read-only board view - [ ] Export board → PDF / image grid - [ ] Optional remote storage adapters (S3 direct, R2) diff --git a/backend/auth.js b/backend/auth.js index 9f6a013..73cd14b 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -1,27 +1,31 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); -const JWT_SECRET = process.env.JWT_SECRET || (() => { - if (process.env.NODE_ENV === 'production') { - throw new Error('JWT_SECRET environment variable is required in production'); - } - return 'refboard-dev-secret-do-not-use-in-prod'; -})(); - const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; const BCRYPT_ROUNDS = 12; const REFBOARD_API_KEY = process.env.REFBOARD_API_KEY || ''; +// Memoized — resolved lazily so db.js is required after its module +// initialization side-effects have run. After the first call the secret is +// cached for the life of the process. +let _cachedJwtSecret = null; +function jwtSecret() { + if (_cachedJwtSecret) return _cachedJwtSecret; + const { getOrCreateJwtSecret } = require('./db'); + _cachedJwtSecret = getOrCreateJwtSecret(); + return _cachedJwtSecret; +} + function generateToken(user) { return jwt.sign( { id: user.id, email: user.email, role: user.role, username: user.username, display_name: user.display_name }, - JWT_SECRET, + jwtSecret(), { expiresIn: JWT_EXPIRES_IN } ); } function verifyToken(token) { - return jwt.verify(token, JWT_SECRET); + return jwt.verify(token, jwtSecret()); } async function hashPassword(password) { @@ -122,5 +126,4 @@ module.exports = { adminMiddleware, apiKeyMiddleware, adminOrApiKeyMiddleware, - JWT_SECRET, }; diff --git a/backend/db.js b/backend/db.js index e52a791..93b2d26 100644 --- a/backend/db.js +++ b/backend/db.js @@ -327,6 +327,25 @@ function getBoolSetting(key, defaultValue = false) { } })(); +// JWT secret resolution. +// 1. process.env.JWT_SECRET wins if set (lets ops manage secrets via env). +// 2. Otherwise read from settings.jwt_secret. +// 3. If neither exists, generate a 64-byte random secret and persist it. +// +// This removes the need to set JWT_SECRET in .env before first boot. The +// first boot of a fresh install lands directly on the Login page in "create +// the first admin" mode; tokens minted there are signed with the auto- +// generated secret. +function getOrCreateJwtSecret() { + if (process.env.JWT_SECRET) return process.env.JWT_SECRET; + const existing = getSetting('jwt_secret'); + if (existing) return existing; + const generated = require('crypto').randomBytes(64).toString('base64'); + setSetting('jwt_secret', generated); + console.log('[db] JWT_SECRET was not set in the environment — generated a fresh one and persisted it in the settings table.'); + return generated; +} + // --------------------- // User helpers // --------------------- @@ -823,6 +842,7 @@ module.exports = { getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, // Settings getSetting, setSetting, getAllSettings, getBoolSetting, + getOrCreateJwtSecret, // Activity log logActivity, getBoardActivity, // Bootstrap diff --git a/backend/routes/boards.js b/backend/routes/boards.js index c2d04b3..e26287b 100644 --- a/backend/routes/boards.js +++ b/backend/routes/boards.js @@ -11,7 +11,7 @@ const { getCollection, getCollectionMember, } = require('../db'); -const { deleteBoardImages: deleteBoardMinioImages } = require('../minio'); +const { deleteBoardImages: deleteBoardMinioImages } = require('../storage'); const { recordActivity } = require('../activity'); const router = Router(); diff --git a/backend/routes/collections.js b/backend/routes/collections.js index a17364c..ff12e75 100644 --- a/backend/routes/collections.js +++ b/backend/routes/collections.js @@ -14,7 +14,7 @@ const { getCollectionBoards, getUserByEmail, } = require('../db'); -const { deleteBoardImages: deleteBoardMinioImages } = require('../minio'); +const { deleteBoardImages: deleteBoardMinioImages } = require('../storage'); const router = Router(); diff --git a/backend/routes/upload.js b/backend/routes/upload.js index e99c021..c3c865c 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -7,7 +7,7 @@ const http = require('http'); const { URL } = require('url'); const { authMiddleware } = require('../auth'); const { getBoard, getCollectionMember, createImage, getImage, createMediaJob, createPdfPage, updateImagePageCount } = require('../db'); -const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio'); +const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../storage'); const { pdfInfo, bufferToTempFile } = require('../pdf-utils'); const { recordActivity } = require('../activity'); diff --git a/backend/server.js b/backend/server.js index d0dfb02..0e0fb6a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -23,7 +23,7 @@ app.get('/api/images/*', async (req, res) => { try { const objectPath = req.params[0]; // everything after /api/images/ if (!objectPath) return res.status(400).json({ error: 'Missing path' }); - const { minioClient, MINIO_BUCKET } = require('./minio'); + const { minioClient, MINIO_BUCKET } = require('./storage'); const stat = await minioClient.statObject(MINIO_BUCKET, objectPath); const contentType = stat.metaData?.['content-type'] || 'application/octet-stream'; const totalSize = stat.size; @@ -162,6 +162,10 @@ async function start() { const dbModule = require('./db'); console.log('[server] Database initialized'); + // Resolve/generate the JWT secret eagerly so the "generated a fresh one" + // log line surfaces at boot, not on the first auth request. + dbModule.getOrCreateJwtSecret(); + try { await dbModule.seedAdminFromEnv(); } catch (err) { @@ -169,12 +173,12 @@ async function start() { } try { - const { initBucket } = require('./minio'); + const { initBucket } = require('./storage'); await initBucket(); - console.log('[server] MinIO initialized'); + console.log('[server] Storage backend initialized'); } catch (err) { - console.error('[server] MinIO initialization failed:', err.message); - console.error('[server] Image uploads will not work until MinIO is available'); + console.error('[server] Storage initialization failed:', err.message); + console.error('[server] Image uploads will not work until storage is available'); } // Start media processing worker diff --git a/backend/services/media-worker.js b/backend/services/media-worker.js index 1841775..30305e1 100644 --- a/backend/services/media-worker.js +++ b/backend/services/media-worker.js @@ -16,7 +16,7 @@ const { updatePdfPageHires, } = require('../db'); const { probeVideo, extractPoster } = require('../video-utils'); -const { putBuffer, minioClient, MINIO_BUCKET } = require('../minio'); +const { putBuffer, minioClient, MINIO_BUCKET } = require('../storage'); const { pdfRenderPage, bufferToTempFile } = require('../pdf-utils'); const POLL_INTERVAL_MS = 3000; diff --git a/backend/storage-fs.js b/backend/storage-fs.js new file mode 100644 index 0000000..1f8224f --- /dev/null +++ b/backend/storage-fs.js @@ -0,0 +1,221 @@ +// Local-filesystem storage adapter. +// +// Mirrors backend/minio.js exports so it's a drop-in replacement when +// STORAGE_BACKEND=fs. The exported `minioClient` is a fake that supports the +// subset of MinIO Client methods RefBoard actually calls (putObject, +// statObject, getObject, getPartialObject, removeObject, removeObjects, +// listObjectsV2, bucketExists, makeBucket, setBucketPolicy). +// +// Layout on disk: +// {STORAGE_DATA_DIR}/{bucket}/boards/{boardId}/{imageId}.png +// {STORAGE_DATA_DIR}/{bucket}/boards/{boardId}/{imageId}.png.mime +// +// The sibling .mime sidecar holds the Content-Type so the HTTP range-aware +// media proxy can set the correct response header on read. + +const fs = require('fs'); +const fsp = fs.promises; +const path = require('path'); +const { EventEmitter } = require('events'); + +const MINIO_BUCKET = process.env.MINIO_BUCKET || 'refboard'; +const STORAGE_DATA_DIR = process.env.STORAGE_DATA_DIR || '/app/data/storage'; +const PUBLIC_URL = process.env.PUBLIC_URL || ''; + +const MIME_TO_EXT = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/svg+xml': '.svg', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/quicktime': '.mov', + 'application/pdf': '.pdf', +}; + +function bucketRoot(bucket) { + return path.join(STORAGE_DATA_DIR, bucket); +} + +function objectFile(bucket, name) { + return path.join(bucketRoot(bucket), name); +} + +function metaFile(bucket, name) { + return objectFile(bucket, name) + '.mime'; +} + +function notFound(name) { + const e = new Error(`Object not found: ${name}`); + e.code = 'NoSuchKey'; + return e; +} + +async function readContentType(bucket, name) { + try { + const ct = await fsp.readFile(metaFile(bucket, name), 'utf8'); + return ct.trim() || 'application/octet-stream'; + } catch { + return 'application/octet-stream'; + } +} + +const minioClient = { + async bucketExists(bucket) { + try { + const st = await fsp.stat(bucketRoot(bucket)); + return st.isDirectory(); + } catch { + return false; + } + }, + + async makeBucket(bucket /* , region */) { + await fsp.mkdir(bucketRoot(bucket), { recursive: true }); + }, + + async setBucketPolicy(/* bucket, policyJson */) { + // FS adapter has no concept of public-read policies — the bytes are only + // served through the backend's /api/images/* proxy regardless. + }, + + async putObject(bucket, name, buffer, length, metaDict) { + const target = objectFile(bucket, name); + await fsp.mkdir(path.dirname(target), { recursive: true }); + await fsp.writeFile(target, buffer); + const ct = metaDict && (metaDict['Content-Type'] || metaDict['content-type']); + if (ct) await fsp.writeFile(metaFile(bucket, name), ct); + }, + + async statObject(bucket, name) { + let st; + try { + st = await fsp.stat(objectFile(bucket, name)); + } catch (err) { + if (err.code === 'ENOENT') throw notFound(name); + throw err; + } + const contentType = await readContentType(bucket, name); + return { + size: st.size, + lastModified: st.mtime, + metaData: { 'content-type': contentType }, + }; + }, + + async getObject(bucket, name) { + const target = objectFile(bucket, name); + if (!fs.existsSync(target)) throw notFound(name); + return fs.createReadStream(target); + }, + + async getPartialObject(bucket, name, offset, length) { + const target = objectFile(bucket, name); + if (!fs.existsSync(target)) throw notFound(name); + const end = length > 0 ? offset + length - 1 : undefined; + return fs.createReadStream(target, { start: offset, end }); + }, + + async removeObject(bucket, name) { + const target = objectFile(bucket, name); + await fsp.rm(target, { force: true }); + await fsp.rm(metaFile(bucket, name), { force: true }); + }, + + async removeObjects(bucket, names) { + await Promise.all(names.map((n) => this.removeObject(bucket, n))); + }, + + // Returns an EventEmitter that fires 'data' for each object, then 'end'. + // Matches the shape consumers rely on (see backend/minio.js:97-109). + listObjectsV2(bucket, prefix, recursive) { + const emitter = new EventEmitter(); + const start = path.join(bucketRoot(bucket), prefix || ''); + (async () => { + try { + const stack = [start]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch (err) { + if (err.code === 'ENOENT') continue; + throw err; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (recursive) stack.push(full); + continue; + } + if (entry.name.endsWith('.mime')) continue; + const rel = path.relative(bucketRoot(bucket), full).split(path.sep).join('/'); + emitter.emit('data', { name: rel, size: 0 }); + } + } + emitter.emit('end'); + } catch (err) { + emitter.emit('error', err); + } + })(); + return emitter; + }, +}; + +async function initBucket() { + await fsp.mkdir(bucketRoot(MINIO_BUCKET), { recursive: true }); + console.log(`[storage-fs] Bucket directory ready at ${bucketRoot(MINIO_BUCKET)}`); +} + +async function uploadImage(boardId, imageId, buffer, mimeType) { + const ext = MIME_TO_EXT[mimeType] || '.bin'; + const objectName = `boards/${boardId}/${imageId}${ext}`; + await minioClient.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, { + 'Content-Type': mimeType, + }); + return objectName; +} + +async function putBuffer(objectName, buffer, contentType) { + await minioClient.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, { + 'Content-Type': contentType, + }); + return objectName; +} + +async function deleteImage(objectPath) { + await minioClient.removeObject(MINIO_BUCKET, objectPath); +} + +async function deleteBoardImages(boardId) { + const prefix = `boards/${boardId}/`; + const dir = path.join(bucketRoot(MINIO_BUCKET), prefix); + if (!fs.existsSync(dir)) return 0; + let count = 0; + await fsp.rm(dir, { recursive: true, force: true }); + // Count is approximate (we don't enumerate first); callers only check >0. + count = 1; + return count; +} + +function getImageUrl(objectPath) { + if (PUBLIC_URL) return `${PUBLIC_URL.replace(/\/$/, '')}/api/images/${objectPath}`; + return `/api/images/${objectPath}`; +} + +const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE_MB || '200', 10) * 1024 * 1024; + +module.exports = { + minioClient, + initBucket, + uploadImage, + putBuffer, + deleteImage, + deleteBoardImages, + getImageUrl, + MINIO_BUCKET, + MIME_TO_EXT, + MAX_FILE_SIZE, +}; diff --git a/backend/storage.js b/backend/storage.js new file mode 100644 index 0000000..063e167 --- /dev/null +++ b/backend/storage.js @@ -0,0 +1,18 @@ +// Storage backend router. +// +// STORAGE_BACKEND=fs → local filesystem (single-container deploys, native +// installs, designers who don't want to run MinIO) +// STORAGE_BACKEND=minio → MinIO / any S3-compatible store (default) +// +// Both modules export the same shape (minioClient + helper functions), so +// consumers `require('./storage')` and never touch the backend choice +// directly. + +const backend = (process.env.STORAGE_BACKEND || 'minio').toLowerCase(); + +if (backend === 'fs') { + console.log('[storage] Using local filesystem backend (STORAGE_BACKEND=fs)'); + module.exports = require('./storage-fs'); +} else { + module.exports = require('./minio'); +} diff --git a/docker-compose.yml b/docker-compose.yml index 3492879..f1207b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: restart: unless-stopped refboard: + image: ${REFBOARD_IMAGE:-ghcr.io/metalfinger/refboard:latest} build: . container_name: refboard depends_on: @@ -36,6 +37,8 @@ services: MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} MINIO_BUCKET: ${MINIO_BUCKET:-refboard} + STORAGE_BACKEND: ${STORAGE_BACKEND:-minio} + STORAGE_DATA_DIR: ${STORAGE_DATA_DIR:-/app/data/storage} PUBLIC_URL: ${PUBLIC_URL:-} MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-200} CORS_ORIGIN: ${CORS_ORIGIN:-*} diff --git a/examples/compose/minimal-fs.yml b/examples/compose/minimal-fs.yml new file mode 100644 index 0000000..2d13c86 --- /dev/null +++ b/examples/compose/minimal-fs.yml @@ -0,0 +1,39 @@ +# Single-container RefBoard with local-filesystem storage. +# +# Use this instead of the repo-root docker-compose.yml when you don't want to +# run MinIO. State is stored under ./.docker-data/refboard/ (SQLite + media +# bytes + JWT secret, all together — back this directory up). +# +# Usage: +# cp .env.example .env # most values can stay at defaults +# docker compose -f examples/compose/minimal-fs.yml up -d + +services: + refboard: + image: ${REFBOARD_IMAGE:-ghcr.io/metalfinger/refboard:latest} + container_name: refboard + environment: + PORT: 8000 + NODE_ENV: ${NODE_ENV:-production} + # JWT_SECRET is optional — RefBoard generates one on first boot and + # persists it in the SQLite settings table. Override here if you'd + # rather manage it out-of-band. + JWT_SECRET: ${JWT_SECRET:-} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d} + DB_PATH: /app/data/refboard.db + STORAGE_BACKEND: fs + STORAGE_DATA_DIR: /app/data/storage + PUBLIC_URL: ${PUBLIC_URL:-} + MAX_FILE_SIZE_MB: ${MAX_FILE_SIZE_MB:-200} + CORS_ORIGIN: ${CORS_ORIGIN:-*} + SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-} + SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} + SEED_ADMIN_USERNAME: ${SEED_ADMIN_USERNAME:-} + SEED_ADMIN_DISPLAY_NAME: ${SEED_ADMIN_DISPLAY_NAME:-} + ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false} + REFBOARD_API_KEY: ${REFBOARD_API_KEY:-} + ports: + - "8000:8000" + volumes: + - ./.docker-data/refboard:/app/data + restart: unless-stopped