Sign inSign up

pikapods/docker-discourse

By pikapods

•Updated 3 days ago

A standalone, OCI-friendly Docker image for Discourse

Image
0

3.4K

pikapods/docker-discourse repository overview

⁠docker-discourse

A standalone Docker image for Discourse⁠ with sane plugin management.

Unlike upstream's discourse_docker (a bash launcher that owns the container lifecycle), this image is a plain container: external Postgres, Redis, SMTP, and reverse proxy. It uses Discourse's own DISCOURSE_* env vars unchanged, runs Pitchfork on port 3000, and precompiles core assets at build time for fast cold boot.

This image is maintained by PikaPods⁠, where Discourse is also available as a managed app from $5.3/month — no setup, automatic updates, your own backups.

⁠Prebuilt images

Published to both registries on every release:

ghcr.io/pikapods/docker-discourse:<tag>
docker.io/pikapods/docker-discourse:<tag>

Tags follow upstream's calendar versioning (v2026.4.0) plus a latest alias for the most recent build. compose.yaml defaults to ghcr.io/pikapods/docker-discourse:latest; override with the DISCOURSE_IMAGE env var.

⁠Quick start

# 1. Seed your local .env from the example, then put a secret_key_base in it.
#    .env is gitignored; .env.example shows the variables compose.yaml reads.
cp .env.example .env
sed -i "s|^DISCOURSE_SECRET_KEY_BASE=.*|DISCOURSE_SECRET_KEY_BASE=$(openssl rand -hex 64)|" .env

# 2. Edit compose.yaml to fill in DISCOURSE_HOSTNAME, SMTP, admin email.

# 3. Up.
docker compose up -d

Browse to http://localhost:3000. First-boot admin credentials come from CONTAINER_DISCOURSE_ADMIN_EMAIL / _PASSWORD.

⁠Image layout

PathPurpose
/appDiscourse source tree (mostly immutable; /app/plugins/ is rebuilt at boot, and public/{uploads,backups,assets} are symlinks into /data)
/opt/discourse-plugins-core/Bundled plugins as shipped by upstream (kept outside Rails root to avoid autoloader double-scanning)
/app/plugins/Active plugin set (symlinks; rebuilt at boot)
/app/assets-baked/Precompiled asset snapshot from build time
/usr/local/bundle-baked/Gem bundle snapshot from build time
/data/Operator-owned volume (see below)
⁠/data (mount point)
/data/
├── uploads/          ← user uploads (symlinked from /app/public/uploads)
├── backups/          ← discourse backups (symlinked from /app/public/backups)
├── plugins/          ← cloned third-party plugin sources
└── cache/
    ├── bundle/         ← BUNDLE_PATH; seeded from baked on first boot
    ├── assets/         ← symlinked from /app/public/assets
    └── .plugin-manifest ← sha256 of resolved plugin set

A single volume mounted at /data is enough — the subdirs are internal organisation. Logs go to stdout.

⁠Environment variables

⁠Required
VarNotes
DISCOURSE_HOSTNAMEPublic hostname (no scheme)
DISCOURSE_DB_HOSTPostgres host
DISCOURSE_DB_USERNAME
DISCOURSE_DB_PASSWORD
DISCOURSE_DB_NAME
DISCOURSE_REDIS_HOST
DISCOURSE_SMTP_ADDRESS
DISCOURSE_DEVELOPER_EMAILSComma-separated; these accounts become admins on signup
DISCOURSE_SECRET_KEY_BASEEnv-only, no fallback. Generate once with openssl rand -hex 64 and keep it stable. Rotating it invalidates sessions and breaks encrypted columns.
⁠Common optional DISCOURSE_* (passthrough)

DISCOURSE_PORT (Pitchfork listen port; defaults to 3000), DISCOURSE_DB_PORT, DISCOURSE_DB_POOL, DISCOURSE_REDIS_PORT, DISCOURSE_REDIS_PASSWORD, DISCOURSE_REDIS_USE_SSL, DISCOURSE_SMTP_PORT, DISCOURSE_SMTP_USER_NAME (note the underscore — canonical upstream spelling), DISCOURSE_SMTP_PASSWORD, DISCOURSE_SMTP_DOMAIN, DISCOURSE_SMTP_AUTHENTICATION, DISCOURSE_SMTP_ENABLE_START_TLS, DISCOURSE_SMTP_FORCE_TLS, DISCOURSE_SMTP_OPENSSL_VERIFY_MODE, DISCOURSE_CDN_URL, DISCOURSE_S3_*, DISCOURSE_ENABLE_CORS, DISCOURSE_CORS_ORIGIN.

All DISCOURSE_* vars are read directly by Discourse's config/discourse.conf; they pass through unchanged.

⁠Image-owned CONTAINER_DISCOURSE_*
VarDefaultPurpose
CONTAINER_DISCOURSE_PLUGINS_BUILTINunset → default-6 (checklist, discourse-details, discourse-narrative-bot, discourse-presence, discourse-reactions, styleguide)Allow-list for bundled plugins. "" = none, * = all, "checklist,poll" = exact set. See discourse/discourse/plugins⁠ for the full list of bundled plugins.
CONTAINER_DISCOURSE_PLUGINSemptyThird-party plugin manifest: <url>[@<ref>][#<name>], comma-separated
CONTAINER_DISCOURSE_DB_MIGRATETRUERun rake db:migrate at bootstrap
CONTAINER_DISCOURSE_ENABLE_SIDEKIQTRUEStart the sidekiq longrun
CONTAINER_DISCOURSE_ADMIN_EMAILunsetFirst-boot admin seed (skipped if any admin exists)
CONTAINER_DISCOURSE_ADMIN_PASSWORDunsetRequired when ADMIN_EMAIL is set and no admin exists
CONTAINER_DISCOURSE_ADMIN_USERNAMEadmin
CONTAINER_DISCOURSE_PITCHFORK_WORKERS3Pitchfork worker count
CONTAINER_DISCOURSE_SIDEKIQ_CONCURRENCY5Sidekiq thread count

⁠Plugins

Plugins are full Rails sub-projects: adding one means re-running bundle install (it may declare gems), themes:update, and assets:precompile. This image accepts the cost honestly: the first boot after a plugin change runs all three, and the manifest hash is stored in /data/cache/.plugin-manifest so subsequent boots skip the rebuild. (db:migrate runs every boot regardless, gated by CONTAINER_DISCOURSE_DB_MIGRATE.)

⁠Bundled vs third-party

Discourse ships 50+ plugins under plugins/ in the source tree⁠ ("bundled") — chat, discourse-ai, discourse-narrative-bot, ... These are all baked into the image at /opt/discourse-plugins-core/. Use CONTAINER_DISCOURSE_PLUGINS_BUILTIN to choose which ones are active:

  • unset → the default-6 (checklist, discourse-details, discourse-narrative-bot, discourse-presence, discourse-reactions, styleguide). This is the no-config boot path and it does not trigger a rebuild — the build-time precompile produced assets for exactly this set.
  • "" (empty) → all bundled plugins disabled. Triggers one rebuild.
  • "checklist,poll" → only those two. Short aliases work too (narrative-bot resolves to discourse-narrative-bot).
  • "*" → every plugin in /opt/discourse-plugins-core/. Heavy first rebuild; subsequent boots fast.

Third-party plugins are listed in CONTAINER_DISCOURSE_PLUGINS:

environment:
  CONTAINER_DISCOURSE_PLUGINS: >-
    https://github.com/discourse/discourse-prometheus@main,
    https://github.com/discourse/[email protected],
    https://github.com/discourse/discourse-canned-replies@abc1234

Each entry: <git_url>[@<ref>][#<name>]. The ref can be a branch, tag, or 40-char SHA. SHAs are immutable; mutable refs trigger a rebuild whenever the remote HEAD advances (the manifest hash incorporates git rev-parse HEAD, so a forward-moving main doesn't go unnoticed).

⁠Offline behaviour

When the cache at /data/plugins/ is already at the right ref, a network failure during git fetch is non-fatal — a warning is logged and the cached HEAD is used. This means a healthy boot once is enough to make subsequent offline boots succeed.

A plugin in the manifest that has never been cached and can't be cloned causes the bootstrap to fail loudly.

⁠User & permissions

The container runs as discourse (UID/GID 1000:1000). On a bind mount, chown the host target to 1000:1000 first; on rootless podman use --userns=keep-id:uid=1000,gid=1000. Named volumes need no host-side prep.

Rebuild with a custom UID/GID if the defaults clash:

docker build \
  --build-arg DISCOURSE_UID=$(id -u) \
  --build-arg DISCOURSE_GID=$(id -g) \
  -t docker-discourse:local .

⁠Healthcheck

GET /srv/status — Discourse's own readiness endpoint. Initial start-period is 180s to absorb the first migration on a fresh DB.

Tag summary

Content type

Image

Digest

sha256:8f3ce1b52…

Size

1.1 GB

Last updated

3 days ago

docker pull pikapods/docker-discourse