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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-09 15:49:10 +00:00
commit efac8782ac
8 changed files with 458 additions and 0 deletions

17
.env.example Normal file
View File

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

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
*.backup
*.tar.gz
shared/

49
README.md Normal file
View File

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

99
SPEC.md Normal file
View File

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

81
app.yml Normal file
View File

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

113
scripts/install.sh Executable file
View File

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

18
scripts/post-rebuild.sh Executable file
View File

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

77
scripts/restore-backup.sh Executable file
View File

@ -0,0 +1,77 @@
#!/bin/bash
set -euo pipefail
## Restore a Discourse backup from an existing instance
## Usage: bash scripts/restore-backup.sh <path-to-backup.tar.gz>
DISCOURSE_DIR="/opt/discourse"
BACKUP_DIR="/opt/discourse/shared/standalone/backups/default"
if [ $# -eq 0 ]; then
echo "Usage: $0 <path-to-backup.tar.gz>"
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 <<EOF
discourse enable_restore
EOF
# Restore the backup
echo ""
echo "[3/4] Restoring backup (this may take several minutes)..."
./launcher enter app <<EOF
discourse restore $BACKUP_FILENAME
EOF
# Rebuild to ensure everything is clean
echo ""
echo "[4/4] Rebuilding container to finalize restore..."
./launcher rebuild app
# Reconnect to traefik network after rebuild
CONTAINER_ID=$(docker ps -q -f name=app)
if [ -n "$CONTAINER_ID" ]; then
docker network connect traefik-public "$CONTAINER_ID" 2>/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"