commit efac8782ac083eec5eadab5f1dd193e4fc03519d Author: Jeff Emmett Date: Mon Feb 9 15:49:10 2026 +0000 Initial Discourse deployment config for cadCAD community forum Sets up official Discourse Docker behind Traefik reverse proxy on Netcup RS 8000. Temp domain: cadcad-forum.jeffemmett.com (switching to community.cadcad.org later). Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d848b6f --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Discourse Environment Variables +# Copy to .env and fill in values + +# Admin +DISCOURSE_DEVELOPER_EMAILS=jeff@jeffemmett.com + +# Domain (temporary - switch to community.cadcad.org later) +DISCOURSE_HOSTNAME=cadcad-forum.jeffemmett.com + +# SMTP (configure when email provider is chosen) +# DISCOURSE_SMTP_ADDRESS=smtp.example.com +# DISCOURSE_SMTP_PORT=587 +# DISCOURSE_SMTP_USER_NAME= +# DISCOURSE_SMTP_PASSWORD= +# DISCOURSE_SMTP_ENABLE_START_TLS=true +# DISCOURSE_SMTP_DOMAIN=cadcad.org +# DISCOURSE_NOTIFICATION_EMAIL=noreply@cadcad.org diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47523fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +*.backup +*.tar.gz +shared/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..60c80ed --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# cadCAD Community Discourse Forum + +Self-hosted Discourse forum for the cadCAD community. + +- **Temp URL**: https://cadcad-forum.jeffemmett.com +- **Final URL**: https://community.cadcad.org (pending DNS coordination) +- **Server**: Netcup RS 8000 at `/opt/discourse/` +- **Memory**: 2GB container limit + 2GB swap + +## Quick Reference + +```bash +ssh netcup +cd /opt/discourse + +./launcher logs app # View logs +./launcher restart app # Restart +./launcher rebuild app # Rebuild (after config changes) +./launcher enter app # Shell into container +./launcher stop app # Stop +``` + +After any rebuild, reconnect to Traefik: +```bash +bash scripts/post-rebuild.sh +``` + +## Initial Setup + +1. Run `scripts/install.sh` on Netcup (or see SPEC.md for manual steps) +2. Add `cadcad-forum.jeffemmett.com` to Cloudflare tunnel +3. Create admin account: `./launcher enter app` → `rake admin:create` + +## Import Existing Forum Data + +1. Get `.tar.gz` backup from existing community.cadcad.org admin panel (`/admin/backups`) +2. Upload to Netcup: `scp backup.tar.gz netcup:/tmp/` +3. Run: `bash scripts/restore-backup.sh /tmp/backup.tar.gz` + +## Switch to community.cadcad.org + +1. Edit `containers/app.yml`: update `DISCOURSE_HOSTNAME` and Traefik `Host()` rule +2. `./launcher rebuild app && bash scripts/post-rebuild.sh` +3. Add `community.cadcad.org` to Cloudflare tunnel +4. Coordinate DNS: CNAME `community.cadcad.org` → tunnel UUID `.cfargotunnel.com` + +## Configure Email (SMTP) + +Uncomment and fill in the SMTP section in `containers/app.yml`, then rebuild. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..2310ae4 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,99 @@ +# cadCAD Discourse Forum - Deployment Spec + +## Summary + +Deploy a self-hosted Discourse forum on Netcup RS 8000, initially at `cadcad-forum.jeffemmett.com` (temporary domain), to be switched to `community.cadcad.org` once DNS is coordinated. An existing Discourse backup will be imported later. + +## Architecture Decision: Official Discourse Docker + +**Chosen approach: Official Discourse Docker** (`discourse/discourse_docker`) + +### Why official over alternatives? + +| Criteria | Official | nfrastack (alternative) | +|----------|----------|------------------------| +| Backup import | Best (native) | Untested | +| Long-term support | Discourse team | Community | +| Plugin management | Easy (app.yml) | Env vars | +| Traefik integration | Requires config tweaks | Native | +| Docker-compose native | No (custom launcher) | Yes | + +**Backup compatibility is the deciding factor** since we need to import an existing community.cadcad.org backup later. The official approach is the only one guaranteed to handle this reliably. + +### Trade-off accepted + +The official Discourse Docker uses a custom `./launcher` script instead of standard `docker-compose`. This breaks the pattern used by other services on the stack, but is necessary for reliable backup import/restore. + +## Technical Details + +### Infrastructure +- **Server**: Netcup RS 8000 (64GB RAM, 20 cores) +- **Container type**: Monolithic (PostgreSQL + Redis + Discourse in one container) +- **Memory limit**: 2GB container + 2GB swap (sufficient for <1k users in steady state) +- **Temp domain**: `cadcad-forum.jeffemmett.com` +- **Final domain**: `community.cadcad.org` (DNS controlled by someone else) + +### Routing +``` +Internet → Cloudflare Tunnel → Traefik (:80) → Discourse container (:80 internal) +``` + +Traefik integration via Docker labels in `app.yml`: +- Disable SSL templates (Cloudflare handles TLS) +- Don't expose ports directly (Traefik routes traffic) +- Connect to `traefik-public` network via `docker_args` + +### Email +- Deferred for initial setup +- Will configure SMTP later (Resend or another provider) +- Discourse will warn about missing email but will function for admin access + +### Storage +- Data stored in `/opt/discourse/shared/standalone/` on Netcup +- PostgreSQL data, Redis data, uploads, backups all within the container's shared directory +- Repo at `/opt/discourse/` contains config only (not data) + +## Deployment Steps + +1. **Create repo** with config files locally at `/home/jeffe/Github/cadcad-discourse-forum` +2. **Clone to Netcup** at `/opt/discourse/` +3. **Install official Discourse Docker** (`discourse_docker` launcher) +4. **Configure `app.yml`** with Traefik labels, no SSL, 2GB memory limit +5. **Add Cloudflare tunnel hostname** for `cadcad-forum.jeffemmett.com` +6. **Bootstrap and start** Discourse +7. **Verify** forum is accessible at `cadcad-forum.jeffemmett.com` + +## Files in This Repo + +``` +cadcad-discourse-forum/ +├── SPEC.md # This file +├── README.md # Deployment instructions +├── app.yml # Discourse container config (copied to /opt/discourse/containers/) +├── .env.example # Environment variable template +└── scripts/ + ├── install.sh # Initial setup script (run on Netcup) + └── restore-backup.sh # Backup import script (for later) +``` + +## Migration Plan (Later) + +1. Obtain `.tar.gz` backup from existing community.cadcad.org admin panel +2. Upload to `/opt/discourse/shared/standalone/backups/default/` +3. Run restore: `./launcher enter app` → `discourse restore ` +4. Coordinate DNS change: `community.cadcad.org` CNAME → tunnel +5. Update `app.yml` hostname and rebuild + +## Out of Scope + +- Email configuration (deferred) +- SSO/OAuth integration +- Custom plugins (can be added later via `app.yml`) +- CDN configuration +- Automated backups (Discourse has built-in scheduled backups) + +## Open Questions + +- Which email provider to use when ready (Resend, Mailgun, etc.) +- Who to coordinate with for cadcad.org DNS +- Whether any specific Discourse plugins are needed from the existing instance diff --git a/app.yml b/app.yml new file mode 100644 index 0000000..41cedc4 --- /dev/null +++ b/app.yml @@ -0,0 +1,81 @@ +## Discourse container config for cadCAD community forum +## Deployed behind Traefik reverse proxy on Netcup RS 8000 +## +## This file gets copied to /opt/discourse/containers/app.yml on the server. +## After editing, rebuild with: cd /opt/discourse && ./launcher rebuild app + +templates: + - "templates/postgres.template.yml" + - "templates/redis.template.yml" + - "templates/web.template.yml" + ## SSL handled by Cloudflare/Traefik - do NOT enable these: + # - "templates/web.ssl.template.yml" + # - "templates/web.letsencrypt.ssl.template.yml" + - "templates/web.ratelimited.template.yml" + +## No direct port exposure - Traefik routes traffic via Docker network +## expose: +## - "80:80" + +params: + db_default_text_search_config: "pg_catalog.english" + ## 2GB total container limit, tune DB accordingly + db_shared_buffers: "128MB" + db_work_mem: "10MB" + +env: + LC_ALL: en_US.UTF-8 + LANG: en_US.UTF-8 + LANGUAGE: en_US.UTF-8 + + DISCOURSE_DEFAULT_LOCALE: en + + ## Temporary domain - will switch to community.cadcad.org later + DISCOURSE_HOSTNAME: 'cadcad-forum.jeffemmett.com' + + ## Admin email for initial setup + DISCOURSE_DEVELOPER_EMAILS: 'jeff@jeffemmett.com' + + ## SMTP - configure when ready (forum works without it but can't send emails) + ## Uncomment and fill in when email provider is chosen: + # DISCOURSE_SMTP_ADDRESS: smtp.example.com + # DISCOURSE_SMTP_PORT: 587 + # DISCOURSE_SMTP_USER_NAME: user@example.com + # DISCOURSE_SMTP_PASSWORD: "password" + # DISCOURSE_SMTP_ENABLE_START_TLS: true + # DISCOURSE_SMTP_DOMAIN: cadcad.org + # DISCOURSE_NOTIFICATION_EMAIL: noreply@cadcad.org + + ## Serve behind reverse proxy + DISCOURSE_FORCE_HTTPS: true + +volumes: + - volume: + host: /opt/discourse/shared/standalone + guest: /shared + - volume: + host: /opt/discourse/shared/standalone/log/var-log + guest: /var/log + +hooks: + after_code: + - exec: + cd: $home/plugins + cmd: + ## Add plugins here (uncomment as needed): + # - git clone https://github.com/discourse/docker_manager.git + # - git clone https://github.com/discourse/discourse-solved.git + # - git clone https://github.com/discourse/discourse-voting.git + - echo "Plugin installation complete" + +## Memory limit: 2GB container + 2GB swap +run: + - exec: echo "Beginning of custom commands" + +## Traefik integration labels +labels: + app_name: discourse + traefik.enable: "true" + traefik.docker.network: traefik-public + traefik.http.routers.discourse.rule: "Host(`cadcad-forum.jeffemmett.com`)" + traefik.http.services.discourse.loadbalancer.server.port: "80" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..e5a3c4c --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +## Discourse Installation Script for Netcup RS 8000 +## Run this ON the Netcup server: ssh netcup "bash -s" < scripts/install.sh +## Or: ssh netcup, then cd /opt/discourse && bash scripts/install.sh + +DISCOURSE_DIR="/opt/discourse" +REPO_URL="https://github.com/discourse/discourse_docker.git" + +echo "=== Discourse Installation for cadCAD Community Forum ===" +echo "" + +# 1. Check for swap (Discourse needs it for 2GB RAM limit) +echo "[1/6] Checking swap space..." +SWAP_TOTAL=$(free -m | awk '/^Swap:/ {print $2}') +if [ "$SWAP_TOTAL" -lt 2000 ]; then + echo " WARNING: Less than 2GB swap detected (${SWAP_TOTAL}MB)." + echo " Creating 2GB swap file..." + if [ ! -f /swapfile ]; then + fallocate -l 2G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab + echo " Swap file created and enabled." + else + echo " /swapfile already exists. Enabling if not active..." + swapon /swapfile 2>/dev/null || true + fi +else + echo " Swap OK: ${SWAP_TOTAL}MB" +fi + +# 2. Clone discourse_docker +echo "" +echo "[2/6] Setting up Discourse Docker..." +if [ -d "$DISCOURSE_DIR/.git" ]; then + echo " discourse_docker already cloned at $DISCOURSE_DIR" + cd "$DISCOURSE_DIR" + git pull +else + echo " Cloning discourse_docker..." + git clone "$REPO_URL" "$DISCOURSE_DIR" + cd "$DISCOURSE_DIR" +fi + +# 3. Copy app.yml config +echo "" +echo "[3/6] Installing app.yml configuration..." +if [ -f "containers/app.yml" ]; then + echo " Backing up existing app.yml to containers/app.yml.backup" + cp containers/app.yml "containers/app.yml.backup.$(date +%Y%m%d%H%M%S)" +fi + +# The app.yml should already be in the repo root from our config +if [ -f "app.yml" ]; then + cp app.yml containers/app.yml + echo " app.yml copied to containers/" +else + echo " ERROR: app.yml not found in repo root." + echo " Please copy it manually: cp /path/to/app.yml containers/app.yml" + exit 1 +fi + +# 4. Ensure traefik-public network exists +echo "" +echo "[4/6] Checking Docker networks..." +if docker network inspect traefik-public >/dev/null 2>&1; then + echo " traefik-public network exists." +else + echo " Creating traefik-public network..." + docker network create traefik-public +fi + +# 5. Bootstrap Discourse (this takes 5-10 minutes) +echo "" +echo "[5/6] Bootstrapping Discourse container..." +echo " This will take 5-10 minutes. Building image and precompiling assets..." +echo "" +./launcher bootstrap app + +# 6. Start and connect to Traefik network +echo "" +echo "[6/6] Starting Discourse and connecting to Traefik..." +./launcher start app + +# Connect to traefik-public network +CONTAINER_ID=$(docker ps -q -f name=app) +if [ -n "$CONTAINER_ID" ]; then + docker network connect traefik-public "$CONTAINER_ID" 2>/dev/null || echo " Already connected to traefik-public" + echo " Container connected to traefik-public network." +else + echo " WARNING: Could not find running discourse container." +fi + +echo "" +echo "=== Discourse Installation Complete ===" +echo "" +echo "Next steps:" +echo " 1. Add cadcad-forum.jeffemmett.com to Cloudflare tunnel" +echo " 2. Create admin account:" +echo " cd /opt/discourse && ./launcher enter app" +echo " rake admin:create" +echo " 3. Configure SMTP in containers/app.yml when ready" +echo " 4. Visit https://cadcad-forum.jeffemmett.com" +echo "" +echo "Useful commands:" +echo " ./launcher logs app # View logs" +echo " ./launcher restart app # Restart" +echo " ./launcher rebuild app # Rebuild after config changes" +echo " ./launcher enter app # Shell into container" +echo " ./launcher stop app # Stop" diff --git a/scripts/post-rebuild.sh b/scripts/post-rebuild.sh new file mode 100755 index 0000000..e90866e --- /dev/null +++ b/scripts/post-rebuild.sh @@ -0,0 +1,18 @@ +#!/bin/bash +## Reconnect Discourse container to traefik-public network after rebuild +## Run after every: ./launcher rebuild app +## +## The official launcher creates a NEW container on rebuild, which loses +## the external network connection. This script reconnects it. + +CONTAINER_ID=$(docker ps -q -f name=app) + +if [ -z "$CONTAINER_ID" ]; then + echo "ERROR: Discourse container not running. Start it first: ./launcher start app" + exit 1 +fi + +echo "Connecting Discourse container to traefik-public network..." +docker network connect traefik-public "$CONTAINER_ID" 2>/dev/null && \ + echo "Connected successfully." || \ + echo "Already connected." diff --git a/scripts/restore-backup.sh b/scripts/restore-backup.sh new file mode 100755 index 0000000..15b2732 --- /dev/null +++ b/scripts/restore-backup.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -euo pipefail + +## Restore a Discourse backup from an existing instance +## Usage: bash scripts/restore-backup.sh + +DISCOURSE_DIR="/opt/discourse" +BACKUP_DIR="/opt/discourse/shared/standalone/backups/default" + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "" + echo "Steps to get a backup from the existing community.cadcad.org:" + echo " 1. Log into the existing Discourse as admin" + echo " 2. Go to /admin/backups" + echo " 3. Click 'Backup' to create a new backup" + echo " 4. Download the .tar.gz file" + echo " 5. Upload to Netcup: scp backup.tar.gz netcup:/tmp/" + echo " 6. Run: bash $0 /tmp/backup.tar.gz" + exit 1 +fi + +BACKUP_FILE="$1" + +if [ ! -f "$BACKUP_FILE" ]; then + echo "ERROR: Backup file not found: $BACKUP_FILE" + exit 1 +fi + +echo "=== Discourse Backup Restore ===" +echo "Backup file: $BACKUP_FILE" +echo "" + +# Copy backup to Discourse backup directory +echo "[1/4] Copying backup to Discourse backup directory..." +mkdir -p "$BACKUP_DIR" +cp "$BACKUP_FILE" "$BACKUP_DIR/" +BACKUP_FILENAME=$(basename "$BACKUP_FILE") +echo " Copied to $BACKUP_DIR/$BACKUP_FILENAME" + +# Enable restore mode +echo "" +echo "[2/4] Enabling restore mode..." +cd "$DISCOURSE_DIR" +./launcher enter app </dev/null || true +fi + +echo "" +echo "=== Restore Complete ===" +echo "" +echo "The forum should now contain all data from the backup." +echo "Visit https://cadcad-forum.jeffemmett.com to verify." +echo "" +echo "If switching to community.cadcad.org:" +echo " 1. Update DISCOURSE_HOSTNAME in containers/app.yml" +echo " 2. Update Traefik router rule in containers/app.yml" +echo " 3. ./launcher rebuild app" +echo " 4. Update Cloudflare tunnel + DNS"