Sign inSign up

pikapods/docker-discourse

By pikapods

Updated about 4 hours ago

A standalone, OCI-friendly Docker image for Discourse

Image
0

2.9K

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:ea2ff375a

Size

1.1 GB

Last updated

about 4 hours ago

docker pull pikapods/docker-discourse