sudo -s apt-get install git
git clone https://github.com/discourse/discourse_docker.git /var/discourse cd /var/discourse chmod 700 containers
Before running discourse-setup, edit ~/var/discourse/containers/app.yml:
templates: - "templates/postgres.template.yml" - "templates/redis.template.yml" - "templates/web.template.yml" - "templates/web.ratelimited.template.yml" ## SSL templates - COMMENTED OUT for reverse proxy setup # - "templates/web.ssl.template.yml" # - "templates/web.letsencrypt.ssl.template.yml"
expose: - "8080:80" # expose container port 80 on host port 8080 # - "443:443" # https - commented out
Rationale: Reverse proxy (nginx) will handle TLS termination and forward HTTP traffic to port 8080. Configuring this before setup avoids needing to rebuild the container later.
./discourse-setup --skip-connection-test
Note: This process takes time. Good opportunity for a coffee break.
Configuration Parameters:
Provide these values during setup:
example.comadmin@example.com587 for TLSOFF (critical - disables Let's Encrypt since TLS will be handled by reverse proxy)From your local machine (or source location), copy the backup file:
scp -P <ssh-port> backup-file.tar.gz user@example.com:/var/discourse/shared/standalone/backups/default/
Notes:
<ssh-port> with your SSH port (default is 22, use -P 22 or omit if default)backup-file.tar.gz with your actual backup filenamehttp://example.com (or server IP if DNS not yet configured)Note: The restoration process will restart Discourse and may take several minutes depending on backup size.
#This nginx setup in this guide is but one valid configuration.
apt-get install nginx
Note: This configuration represents one working approach for proxying Discourse through nginx. Alternative configurations may work equally well depending on your specific requirements, existing infrastructure, or preferred nginx patterns.
Create /etc/nginx/sites-available/example.com:
server { listen 80; server_name example.com; location / { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; # Required for Discourse proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; # Buffer settings proxy_buffering off; proxy_redirect off; } client_max_body_size 10m; }
Key Discourse Requirements:
X-Forwarded-For and X-Forwarded-Host headersproxy_http_version 1.1, Upgrade, Connection headers)proxy_buffering off)client_max_body_size 10m)# Remove default site (prevents conflicts) rm /etc/nginx/sites-enabled/default # Create symlink to enable site ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/ # Test configuration nginx -t # Reload nginx systemctl reload nginx
snap install --classic certbot
certbot
Certbot will run interactively and:
systemctl reload nginx
Your Discourse instance should now be accessible at https://example.com
app.yml8080:80 port mapping for reverse proxy setups is not well-documented in official Discourse guidesOFF for Let's Encrypt email is not clearly documentedls /etc/nginx/sites-enabled/ls -la /etc/nginx/sites-enabled/example.comnginx -tsystemctl reload nginxsudo nginx -t/var/discourse/shared/standalone/backups/default/ls -la /var/discourse/shared/standalone/backups/default/.tar.gz==== SCP Syntax Issues ====https://wiki.scorpi.us/doku.php?id=discourse&do=
-P not lowercase -pscp -P <port> <source> <user>@<host>:<destination>scp -P 22 backup.tar.gz user@host:/path/```
Topic Events
Post Events
User Events
Group Events
Category Events
Tag Events
Chat Events (Critical for sync)
Other Events
Content Type: application/json (recommended for easy parsing)
Secret: Optional string for generating HMAC signatures (implement signature verification to prevent spoofed webhooks)
Filtering Options:
TLS Certificate Check: Enable unless using self-signed certs in development
hobbiesync webhook:
general webhook:
Path structure: /chat/hooks/[uuid] suggests custom webhook handler service
Base path: /chat/api/channels
List channels
GET /chat/api/channels
Get specific channel
GET /chat/api/channels/{channel_id}
List messages
GET /chat/api/channels/{channel_id}/messages
Send message
POST /chat/api/channels/{channel_id}/messages
Discourse API requires headers:
Api-Key: {your_api_key}
Api-Username: {bot_username}
API Credentials:
[REDACTED]syncbotSecurity Notes:
Primary focus:
Discourse → Discord:
Discord → Discourse:
/chat/api/channels/{channel_id}/messagesLoop Prevention:
Required mappings to store:
Options:
Recommendation: Use selective events and filtering to reduce noise and processing load
Webhook Receiver (Discourse → Discord):
Discord Bot (Discord → Discourse):
Message History/Backfill:
GET /chat/api/channels/{channel_id}/messages for initial syncThe bridge synchronizes profile images/avatars bidirectionally between Discord and Discourse using webhook-based user impersonation.
avatar_template field/user_avatar/site/username/{size}/123_2.pngbuild_avatar_url() to construct full URL:{size} placeholder with pixel size (default: 128)avatar_url parameter# Avatar URL construction (bridge.py lines 160-186) def build_avatar_url(self, avatar_template: str, size: int = 128) -> Optional[str]: if not avatar_template: return None avatar_path = avatar_template.replace('{size}', str(size)) if not avatar_path.startswith('/'): avatar_path = '/' + avatar_path return f"{self.base_url}{avatar_path}"
Discord user avatars are not synced to Discourse. Messages from Discord appear with the bot's configured username in Discourse, prefixed with [Discord] username: to identify the source user.
The bridge uses Discord webhooks to display messages from Discourse users with their actual:
This creates a seamless experience where Discourse users appear as distinct users in Discord channels.
message.attachmentsdownload_image()/uploads.json?type=image endpointuploads field in Discourse messagesThe bridge detects and converts Giphy/Tenor share URLs to direct media URLs for proper embedding:
https://giphy.com/gifs/… → https://media.giphy.com/media/{ID}/giphy.gifhttps://tenor.com/view/… → https://media.tenor.com/{ID}/tenor.gifWhen Giphy/Tenor returns MP4 URLs (common for larger animations), the bridge converts them to GIF using FFmpeg:
FFmpeg configuration options in bridge_config.json:
{
"bridge": {
"ffmpeg_threads": 3,
"ffmpeg_duration_limit": 10,
"ffmpeg_fps": 15,
"ffmpeg_scale_width": 480
}
}
The bridge tracks processed message IDs to prevent duplicates and enable resumption after restarts.
State file structure (bridge_state.json):
{
"discord_channels": {
"channel_id": {
"last_message_id": 1234567890,
"last_updated": "2024-01-15T12:34:56Z"
}
},
"discourse_channels": {
"channel_id": {
"last_message_id": 456,
"last_updated": "2024-01-15T12:34:56Z"
}
}
}
On first run, the bridge initializes state with the latest message IDs to prevent flooding channels with historical messages.
The bridge implements multiple safeguards to prevent infinite message loops:
[Discord] tag[Discord] prefix are ignored{
"discord": {
"token": "bot_token_here",
"channels": [
{
"name": "channel_name",
"discord_channel_id": 123456789012345678,
"discourse_channel_id": 2,
"discord_webhook_url": null,
"discourse_webhook_url": "https://discourse.example.com/chat/hooks/UUID",
"enabled": true
}
]
},
"discourse": {
"base_url": "https://discourse.example.com",
"api_key": "api_key_here",
"username": "syncbot"
},
"bridge": {
"polling_interval": 1.5,
"state_file": "bridge_state.json",
"log_level": "INFO",
"rate_limit_delay": 1.0,
"use_discord_webhooks": true,
"ffmpeg_threads": 3,
"ffmpeg_duration_limit": 10,
"ffmpeg_fps": 15,
"ffmpeg_scale_width": 480
}
}
| Option | Default | Description |
|---|---|---|
polling_interval | 1.5 | Seconds between Discourse API polls |
state_file | bridge_state.json | Path to state persistence file |
log_level | INFO | Logging verbosity (DEBUG, INFO, WARNING, ERROR) |
rate_limit_delay | 1.0 | Minimum seconds between API calls |
use_discord_webhooks | true | Enable user impersonation via webhooks |
ffmpeg_threads | 3 | CPU threads for MP4 conversion |
ffmpeg_duration_limit | 10 | Max seconds of video to convert |
ffmpeg_fps | 15 | Frame rate for converted GIFs |
ffmpeg_scale_width | 480 | Width in pixels for converted GIFs |
DISCORD_TOKEN - Discord bot tokenDISCOURSE_API_KEY - Discourse API keyDISCOURSE_USERNAME - Discourse bot usernameDISCOURSE_BASE_URL - Discourse instance URLLOG_LEVEL - Logging levelCONFIG_FILE - Path to config fileSTATE_FILE - Path to state fileThe bridge supports multiple independent channel pairs. Each pair has:
discord.channels array in configdiscord_channel_id and discourse_channel_iddiscourse_webhook_urlenabled: trueservices: syncbot: build: . restart: unless-stopped deploy: resources: limits: cpus: '1' memory: 512M logging: driver: "json-file" options: max-size: "10m" max-file: "3" volumes: - ./bridge-config.json:/app/bridge-config.json:ro - ./bridge_state.json:/app/bridge_state.json
Discord User posts message
↓
on_message() event triggered
↓
Check: Is webhook message? → Skip
Check: Is bot message? → Skip
↓
Find channel config by Discord channel ID
↓
Download image attachments (if any)
↓
Upload images to Discourse
↓
Extract/convert Giphy/Tenor URLs
↓
Convert MP4 to GIF (if needed)
↓
Format: "[Discord] username: message"
↓
Append image URLs as markdown
↓
Send via Discourse webhook
↓
Update Discord channel state
poll_discourse() runs every 1.5 seconds
↓
For each enabled channel:
↓
GET messages after last_message_id
↓
For each new message:
↓
Check: Has [Discord] prefix? → Skip
↓
Extract username from user info
Extract avatar_template from user info
↓
Build avatar URL (replace {size}, add base_url)
↓
Extract image uploads, make URLs absolute
↓
Truncate content to 2000 chars if needed
↓
Send via Discord webhook:
- username: Discourse username
- avatar_url: Discourse avatar
- content: message text
↓
Success? → Update Discourse channel state
Failed? → Retry on next poll
discord.py>=2.0.0 - Discord bot frameworkrequests>=2.28.0 - HTTP requestsaiohttp>=3.8.0 - Async HTTPpython-dotenv>=0.19.0 - Environment variablesenabled: true in channel configffmpeg -versionffmpeg_duration_limit or ffmpeg_scale_width