Skip to content

Latest commit

 

History

50 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

discourse-k8s

Custom Docker image and Helm chart for deploying Discourse on Kubernetes.

Created because no well-maintained, free Helm chart exists for Discourse. Bitnami's chart was killed when Broadcom ended free updates (August 2025), and every other option is abandoned. This project provides a working, opinionated setup built on top of discourse/base:slim -- the official Discourse base image with all system dependencies (Ruby, Node, pnpm, ImageMagick, PostgreSQL client) but without bundled PostgreSQL or Redis.

Architecture

+----------------------------------------------------------------------------+
|  Pod                                                                       |
|                                                                            |
|  initContainers (sequential after redis sidecar starts):                   |
|  +-----------------+ +---------------+ +----------------+ +--------------+ |
|  |wait-for-postgres|>|    migrate    |>|    warm-css    |>| create-admin | |
|  |  (busybox:nc)   | |rake db:migrate| | precompile CSS | | (first boot) | |
|  +-----------------+ +---------------+ +----------------+ +--------------+ |
|                                                                            |
|  containers:                                                               |
|  +--------------------+  +--------------------+  +-----------+             |
|  |       web          |  |     sidekiq        |  |   redis   |             |
|  |                    |  |                    |  |  (native  |             |
|  |  pitchfork :3000   |  |  background jobs   |  |  sidecar) |             |
|  |  (web server)      |  |  (job processor)   |  |  :6379    |             |
|  +--------------------+  +--------------------+  +-----------+             |
|          |                        |                   ^                    |
|          +------------------------+-------------------+                    |
|          |                                                                 |
+----------|-----------------------------------------------------------------+
           |
    +------v-------+
    |  PostgreSQL   |     (external, e.g. CNPG)
    |  pg_trgm      |
    |  unaccent      |
    +--------------+

The pod runs three containers from two images:

  • web -- Pitchfork web server (Discourse's unicorn successor) on port 3000. Serves the Rails application and static assets.
  • sidekiq -- Background job processor. Handles email, notifications, indexing, and other async work. Same Discourse image, different command.
  • redis -- redis:7-alpine native sidecar (K8s 1.28+). Ephemeral (emptyDir, no PVC). Used for Sidekiq job queue, caching, and MessageBus real-time delivery. Not a data store -- PostgreSQL is the source of truth.

Init container sequence

  1. redis (native sidecar) -- Starts first with restartPolicy: Always, which makes it a sidecar init container that keeps running alongside regular containers. This is a Kubernetes 1.28+ feature.
  2. wait-for-postgres -- Polls the PostgreSQL host with nc until it responds.
  3. migrate -- Runs bundle exec rake db:migrate. Migrations acquire a distributed mutex via Redis to prevent concurrent runs, which is why Redis must be running before this step.
  4. warm-css -- Runs bundle exec rake assets:precompile:css, pre-compiling theme and plugin CSS into a shared emptyDir that the web container also mounts, so the first request doesn't pay the ~15-20s cold V8/PostCSS compile that Discourse otherwise runs inline on an ephemeral pod filesystem. Gated by stylesheetWarmup.enabled -- skipped entirely when disabled.
  5. create-admin -- Creates the admin user if it doesn't exist (first boot only). Uses the email from discourse.developerEmails and password from discourse.admin.existingSecret. Idempotent -- skips if the user already exists. Only runs when an admin password is configured.

Why Redis as a native sidecar?

Discourse's db:migrate acquires a distributed lock through Redis, so Redis must be available before migrations run. A regular sidecar container (in containers:) starts in parallel with other containers -- there is no ordering guarantee. A native sidecar init container (init container with restartPolicy: Always) starts before the subsequent init containers and regular containers, guaranteeing Redis is up when the migrate init container runs.

Redis as a sidecar rather than a separate Deployment keeps things simple -- it is purely ephemeral cache/queue, shares localhost with the pod, and does not need its own Service or PVC.

Why a custom Docker image?

Discourse plugins contribute JavaScript and CSS to the compiled asset bundle. They must be present when rake assets:precompile runs -- you cannot install them at runtime. The custom image pins a Discourse release tag, installs gems and JS dependencies, and precompiles assets so pods start in seconds instead of 5-15 minutes.

Prerequisites

  • Kubernetes 1.28+ -- Required for native sidecar support (restartPolicy: Always on init containers)
  • External PostgreSQL with pg_trgm and unaccent extensions (auto-created by db:migrate, but the user must have permission)
  • Helm 3
  • Docker (only if building the custom image yourself)

Quick Start

1. Get the Docker image

Option A: Use the pre-built image from GHCR

ghcr.io/anatoly-lab/discourse-k8s:v0.5.0

Option B: Build your own

docker build -t my-registry/discourse:v2026.3.0 docker/

2. Prepare PostgreSQL

Create a database with the required extensions:

CREATE DATABASE discourse;
\c discourse
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;

3. Generate a secret key

openssl rand -hex 64

4. Create a values file

# my-values.yaml
image:
  repository: ghcr.io/anatoly-lab/discourse-k8s
  tag: v0.5.0

discourse:
  hostname: forum.example.com
  developerEmails: "admin@example.com"

  admin:
    email: "admin@example.com"
    password: "changeme1234"   # min 10 chars, break-glass access

  database:
    host: postgres.svc.cluster.local
    name: discourse
    username: discourse
    password: "your-db-password"

  secretKeyBase:
    value: "your-128-char-hex-string"

  smtp:
    address: smtp.example.com
    port: 587
    domain: example.com
    username: "smtp-user"
    password: "smtp-password"

5. Install

From GHCR OCI registry:

helm install discourse oci://ghcr.io/anatoly-lab/helm-charts/discourse \
  --version 0.3.0 \
  -f my-values.yaml \
  --namespace discourse \
  --create-namespace

From a local checkout:

helm install discourse chart/ \
  -f my-values.yaml \
  --namespace discourse \
  --create-namespace

6. Wait for first boot

First startup takes a few minutes while init containers run (migrations + admin creation). Monitor progress:

# Watch migrations
kubectl logs -f deploy/discourse -c migrate -n discourse

# Watch admin creation
kubectl logs -f deploy/discourse -c create-admin -n discourse

# Once init completes, watch the web server
kubectl logs -f deploy/discourse -c web -n discourse

The startup probe allows up to ~10 minutes before marking the pod as failed. On first boot, the create-admin init container creates an admin account using the email from discourse.developerEmails and the password from the configured Secret. On subsequent boots, it detects the user already exists and skips.

Once ready:

kubectl port-forward svc/discourse 3000:80 -n discourse
# Open http://localhost:3000

If OIDC is configured, go to /auth/oidc to log in via your identity provider. The admin password from Vault serves as emergency break-glass access if your identity provider is unavailable.

Parameters

Image parameters

Name Description Value
image.repository Discourse container image repository (required) ""
image.tag Discourse container image tag (defaults to Chart.appVersion if empty) ""
image.pullPolicy Discourse container image pull policy IfNotPresent
imagePullSecrets Docker registry secret names as an array []
nameOverride String to partially override the release name ""
fullnameOverride String to fully override the release name ""

Redis sidecar parameters

Name Description Value
redis.image.repository Redis sidecar image repository redis
redis.image.tag Redis sidecar image tag 7-alpine
redis.image.pullPolicy Redis sidecar image pull policy IfNotPresent
redis.resources.requests.memory Redis sidecar memory request 64Mi
redis.resources.requests.cpu Redis sidecar CPU request 50m
redis.resources.limits.memory Redis sidecar memory limit 128Mi

Discourse parameters

Name Description Value
discourse.hostname Public hostname for the forum (required) ""
discourse.developerEmails Comma-separated emails that get initial admin access (required for first boot) ""
discourse.admin.username Admin account username (first boot only) "admin"
discourse.admin.email Admin account email (first boot only) ""
discourse.admin.name Admin account display name (first boot only) "Admin User"
discourse.admin.password Admin account password, min 10 chars (ignored if existingSecret is set) ""
discourse.admin.existingSecret Name of an existing Secret containing the admin password ""
discourse.admin.secretKey Key within the existing Secret for the admin password admin-password
discourse.database.host PostgreSQL host (required) ""
discourse.database.port PostgreSQL port 5432
discourse.database.name PostgreSQL database name discourse
discourse.database.username PostgreSQL username discourse
discourse.database.pool PostgreSQL connection pool size 8
discourse.database.password PostgreSQL password (ignored if existingSecret is set) ""
discourse.database.existingSecret Name of an existing Secret containing the database password ""
discourse.database.secretKey Key within the existing Secret for the database password db-password
discourse.redis.host Redis host (defaults to localhost sidecar) localhost
discourse.redis.port Redis port 6379
discourse.smtp.address SMTP server address ""
discourse.smtp.port SMTP server port 587
discourse.smtp.domain SMTP HELO domain ""
discourse.smtp.username SMTP username ""
discourse.smtp.authentication SMTP authentication method (plain, login, or cram_md5) plain
discourse.smtp.enableStartTls Enable STARTTLS for SMTP true
discourse.smtp.password SMTP password (ignored if existingSecret is set) ""
discourse.smtp.existingSecret Name of an existing Secret containing the SMTP password ""
discourse.smtp.secretKey Key within the existing Secret for the SMTP password smtp-password
discourse.secretKeyBase.value Rails secret key base, 128-char hex string (ignored if existingSecret is set) ""
discourse.secretKeyBase.existingSecret Name of an existing Secret containing the secret key base ""
discourse.secretKeyBase.secretKey Key within the existing Secret for the secret key base secret-key-base
discourse.serveStaticAssets Serve static assets directly from Pitchfork (no nginx in front) true
discourse.forceHttps Force HTTPS (maps to DISCOURSE_FORCE_HTTPS) true
discourse.unicornWorkers Number of Pitchfork worker processes 3
discourse.sidekiqConcurrency Sidekiq concurrency (number of threads processing background jobs) 5
discourse.extraEnv Array of extra environment variables for all Discourse containers (web, sidekiq, migrate) []

Note: Every secret field (database, smtp, secretKeyBase, admin) supports two patterns -- an inline password/value for simple setups, or existingSecret + secretKey to reference a pre-existing Kubernetes Secret (for Vault, ESO, or similar operators).

Rate limit parameters

Per-IP request budgets enforced by Discourse's request_tracker Rack middleware (lib/middleware/request_tracker.rb). Defaults mirror Discourse upstream (config/discourse_defaults.conf).

Name Description Value
rateLimits.perIpPer10Seconds Per-IP budget for non-asset routes, 10-second rolling window (DISCOURSE_MAX_REQS_PER_IP_PER_10_SECONDS) 50
rateLimits.perIpPerMinute Per-IP budget for non-asset routes, 60-second window (DISCOURSE_MAX_REQS_PER_IP_PER_MINUTE) 200
rateLimits.assetPerIpPer10Seconds Per-IP budget for /assets/*, /uploads/*, /user_avatar/*, /svg-sprite, etc. (DISCOURSE_MAX_ASSET_REQS_PER_IP_PER_10_SECONDS) 200
rateLimits.mode Enforcement mode: block | warn | warn+block | none (DISCOURSE_MAX_REQS_PER_IP_MODE) block

Background: Global rate limits and throttling in Discourse.

Why tune them? The upstream defaults assume a small forum behind nginx, which serves /assets/* and /uploads/* directly without ever hitting Rails. In a K8s deployment without a CDN or nginx in front, every asset request counts against the per-IP budget -- a single page load on a plugin-heavy install can fan out to 50+ asset requests and burn through the 200/10s asset budget, producing spurious 429s for legitimate users.

Trade-offs:

  • Too high: weaker DDoS / scraper protection -- a single hostile IP can saturate Pitchfork workers.
  • Too low: legitimate users (especially behind shared NAT or corporate egress) get 429'd on normal browsing.

Community-recommended ranges:

Profile perIpPer10Seconds perIpPerMinute assetPerIpPer10Seconds mode
Upstream default (small forum, nginx-fronted assets) 50 200 200 block
Pfaffman-recommended for typical production 200 400 200 block
Plugin-heavy install without CDN 400 800 2000 warn+block

Tip: Set rateLimits.mode: warn+block while tuning -- it blocks AND logs every trip to Rails production.log, so you can grep RateLimiter to see which IPs and routes are tripping the limit before deciding whether to raise budgets.

Sidekiq container parameters

Name Description Value
sidekiq.resources.requests.memory Sidekiq container memory request 512Mi
sidekiq.resources.requests.cpu Sidekiq container CPU request 250m
sidekiq.resources.limits.memory Sidekiq container memory limit 1Gi

Init container parameters

Name Description Value
initContainers.waitForPostgres.enabled Enable init container that waits for PostgreSQL to be reachable true
initContainers.waitForPostgres.image.repository Wait-for-postgres init container image repository busybox
initContainers.waitForPostgres.image.tag Wait-for-postgres init container image tag "1.37"
initContainers.waitForPostgres.image.pullPolicy Wait-for-postgres init container image pull policy IfNotPresent

Theme-CSS warmup parameters

Name Description Value
stylesheetWarmup.enabled Run the warm-css init container that precompiles theme CSS before serving (prevents a cold ~15-20s compile on the first request) true
stylesheetWarmup.resources.requests.memory warm-css init container memory request 1Gi
stylesheetWarmup.resources.requests.cpu warm-css init container CPU request 500m
stylesheetWarmup.resources.limits.memory warm-css init container memory limit (sized for OOM headroom; effectively free since init resources count as max, not sum) 3Gi

Diagnostics parameters

Ptrace-free, in-process introspection for capturing where a web request is blocked when external tracers can't attach (the cluster runs ptrace_scope=2). Both are scoped to the web container only, are OFF by default, and add zero overhead when off. Each toggle sets an env var (DISCOURSE_DIAG_RBTRACE / DISCOURSE_DIAG_SIGDUMP) that a Rails initializer baked into the image reads and requires the gem from — after Bundler has set up the bundle, so the bundled gem actually loads. (We can't use RUBYOPT: its preloads run at interpreter startup, before Bundler puts the bundle on the load path, so a bundled gem isn't loadable and the pod crashes at boot.) Requires image tag v0.10.0 or newer (the one that ships the initializer); on an older image the toggle is simply inert. See Diagnosing a request freeze for capture commands.

One image and chart for dev and prod. These toggles default to false, so the same image and chart run unchanged in both environments — the baked-in diagnostic gems are never required, with zero runtime cost. The image always runs RAILS_ENV=production; "debug" here means loading a diagnostic gem, not Rails development mode. So you don't build or maintain a separate "debug image":

  • Prod: leave diagnostics off. Flip a toggle on only to capture a specific incident, then turn it back off.
  • Dev: enable whenever useful — same flags, same image.
  • A toggle changes a container env var, so it takes effect on the next pod roll, not hot: helm upgrade … --set diagnostics.sigdump.enabled=true rolls the web pod (with strategy: Recreate + 1 replica, a brief restart) and the gem loads at the new pod's boot. Set it back to false and upgrade again to fully unload it.
Name Description Value
diagnostics.rbtrace.enabled Sets DISCOURSE_DIAG_RBTRACE=1, so the image initializer requires rbtrace for live method/backtrace tracing over a SysV message queue. Ships in Discourse core. Best-effort: attaching needs the node's kernel.msgmax/kernel.msgmnb raised; on a stock node the attach fails. Prefer sigdump. false
diagnostics.sigdump.enabled Sets DISCOURSE_DIAG_SIGDUMP=1, so the image initializer requires sigdump/setup to dump every thread's Ruby backtrace to a file on a signal (default SIGCONT). The sigdump gem is in the image bundle (see Building the Custom Image). Requires image tag v0.10.0+; inert on older images. false

Persistence parameters

Name Description Value
persistence.uploads.enabled Enable persistent storage for user uploads, avatars, and attachments true
persistence.uploads.size Size of the uploads PVC 10Gi
persistence.uploads.storageClass Storage class for the uploads PVC (empty string uses cluster default) ""
persistence.uploads.accessModes Access modes for the uploads PVC ["ReadWriteOnce"]
persistence.uploads.existingClaim Name of an existing PVC to use for uploads ""
persistence.uploads.mountPath Mount path for uploads inside the container /var/www/discourse/public/uploads
persistence.backups.enabled Enable persistent storage for Discourse backup archives false
persistence.backups.size Size of the backups PVC 10Gi
persistence.backups.storageClass Storage class for the backups PVC (empty string uses cluster default) ""
persistence.backups.accessModes Access modes for the backups PVC ["ReadWriteOnce"]
persistence.backups.existingClaim Name of an existing PVC to use for backups ""
persistence.backups.mountPath Mount path for backups inside the container /var/www/discourse/public/backups

S3 / R2 storage for user uploads

The chart can offload user uploads (avatars, attachments, images) to an S3-compatible bucket (AWS S3, MinIO, Cloudflare R2, GCS interop) instead of the local uploads PVC. Discourse's compiled JS/CSS assets are always served locally by the app — the chart never enables the global S3 asset path.

Under the hood the chart emits the SiteSetting-shadow vars DISCOURSE_ENABLE_S3_UPLOADS=true + DISCOURSE_S3_UPLOAD_BUCKET and never the global DISCOURSE_S3_BUCKET, so Discourse's use_s3? stays false and the compiled assets are never welded onto S3. That means there is no asset-upload init container, no asset CORS policy to manage, and no stale-asset accumulation in the bucket across deploys.

Gotcha: don't set the global DISCOURSE_S3_BUCKET yourself via extraEnv — it flips use_s3? true and welds the compiled assets onto S3, which is exactly what this chart avoids. Use the discourse.s3.* values below instead.

Name Description Value
discourse.s3.enabled Turn on S3/R2 for user uploads false
discourse.s3.region AWS-style region (auto for R2, us-east-1 for MinIO) ""
discourse.s3.bucket Upload bucket — maps to the SiteSetting DISCOURSE_S3_UPLOAD_BUCKET (may be bucket or bucket/path-prefix) ""
discourse.s3.endpoint Custom S3 endpoint for non-AWS providers (leave empty for real AWS S3) ""
discourse.s3.cdnUrl Optional CDN in front of the bucket for serving uploads ""
discourse.s3.installCorsRule Install a bucket CORS rule via PutBucketCors (set false for Cloudflare R2) true

Credentials are supplied via discourse.s3.accessKeyId / discourse.s3.secretAccessKey, an existingSecret, or discourse.s3.useIamProfile: true (EKS IRSA / instance profile). Database backups can also go to S3 via discourse.s3.backups.enabled + discourse.s3.backups.bucket.

Cloudflare R2. Set discourse.s3.installCorsRule: false (R2 rejects the PutBucketCors call Discourse makes in its CORS-install prereq — set the CORS policy on the bucket manually instead) and discourse.s3.region: auto. Point discourse.s3.endpoint at the R2 S3 endpoint (https://<accountid>.r2.cloudflarestorage.com) and discourse.s3.cdnUrl at a public R2 URL or custom domain.

OIDC / SSO

The chart has a first-class discourse.oidc.* block for the discourse-openid-connect plugin (Keycloak, Auth0, any OIDC provider) so you configure SSO with named chart values instead of raw extraEnv. Non-secret settings render into the ConfigMap; the client secret is wired through the chart's secret machinery (mirroring discourse.s3).

Set discourse.oidc.enabled: true to turn it on. When disabled (the default) no OIDC env vars and no secret entry are rendered, so existing installs are unaffected.

Name Description Value
discourse.oidc.enabled Master toggle; when false no OIDC env vars are emitted false
discourse.oidc.discoveryDocument OIDC discovery URL (.well-known/openid-configuration) ""
discourse.oidc.clientId OAuth client ID registered with the IdP ""
discourse.oidc.authorizeScope Scopes requested at authorize time "openid email profile"
discourse.oidc.usePkce Use PKCE for the authorization code flow true
discourse.oidc.overridesEmail Let the IdP's email override the Discourse account email false
discourse.oidc.rpInitiatedLogout Enable RP-initiated logout (log out of the IdP too) false
discourse.oidc.rpInitiatedLogoutRedirect Post-logout redirect URL — blank ⇒ env var omitted (plugin omits post_logout_redirect_uri) ""
discourse.oidc.clientSecret Inline OAuth client secret (ignored if existingSecret is set) ""
discourse.oidc.existingSecret Name of an existing Secret holding the client secret ""
discourse.oidc.secretKeys.clientSecret Key within the Secret that holds the client secret oidc-client-secret
discourse.allowedInternalHosts SSRF-bypass allowlist (e.g. an internal Keycloak host); pipe-separated for multiple (a.internal|b.internal); emitted as DISCOURSE_ALLOWED_INTERNAL_HOSTS only when non-empty. NOT OIDC-specific ""

The client secret is supplied one of two ways:

  • existingSecret + secretKeys.clientSecret — reference a pre-existing Kubernetes Secret (preferred for GitOps / Vault / ESO).
  • clientSecret — inline value; the chart then creates a managed Secret (discouraged).

Don't double-set: configure OIDC through discourse.oidc.*, not extraEnv. Setting the same DISCOURSE_OPENID_CONNECT_* var via both is last-wins/undefined.

Example (existing secret, internal Keycloak):

discourse:
  allowedInternalHosts: "keycloak.identity.svc.cluster.local"
  oidc:
    enabled: true
    discoveryDocument: "https://keycloak.identity.svc.cluster.local/realms/myrealm/.well-known/openid-configuration"
    clientId: "discourse"
    existingSecret: "discourse-oidc"
    secretKeys:
      clientSecret: client-secret

Network parameters

Name Description Value
service.type Kubernetes Service type ClusterIP
service.port Service port 80
service.targetPort Container port the Service routes to 3000
service.annotations Additional annotations for the Service {}
ingress.enabled Enable Ingress resource false
ingress.className Ingress class name ""
ingress.host Hostname for the rule + TLS. Empty => derived from discourse.hostname ""
ingress.path Path for the Ingress rule "/"
ingress.pathType Path type for the Ingress rule "Prefix"
ingress.annotations Additional annotations for the Ingress {}
ingress.tls.enabled Enable TLS for the Ingress host false
ingress.tls.secretName Secret holding the TLS cert (required when tls.enabled) ""

Breaking change in 0.11.0: ingress.* shape. The list-form ingress.hosts[]/ingress.tls[] was replaced by a single-host scalar API. Migration:

Old (≤ 0.10.0) New (≥ 0.11.0)
ingress.hosts[0].host derived from discourse.hostname (or set ingress.host to override)
ingress.hosts[0].paths[0].path ingress.path
ingress.hosts[0].paths[0].pathType ingress.pathType
ingress.tls[0].secretName ingress.tls.secretName + ingress.tls.enabled: true
ingress.tls[0].hosts[0] derived (same $host as the rule)

Resource parameters

Name Description Value
resources.requests.memory Web (Pitchfork) container memory request 1Gi
resources.requests.cpu Web (Pitchfork) container CPU request 500m
resources.limits.memory Web (Pitchfork) container memory limit 2Gi

Service account parameters

Name Description Value
serviceAccount.create Create a ServiceAccount for the pod false
serviceAccount.annotations Additional annotations for the ServiceAccount {}
serviceAccount.name Name of the ServiceAccount (auto-generated if empty and create is true) ""

Pod parameters

Name Description Value
nodeSelector Node labels for pod assignment {}
tolerations Tolerations for pod scheduling []
affinity Affinity rules for pod scheduling {}
podAnnotations Additional annotations for the pod {}
podLabels Additional labels for the pod {}
podSecurityContext Pod-level security context. Defaults set fsGroup so the shared stylesheet-cache emptyDir is writable by the discourse user (uid/gid 1000) {"fsGroup":1000,"fsGroupChangePolicy":"OnRootMismatch"}
securityContext Security context for the web and sidekiq containers {}
restartPolicy Pod restart policy Always
terminationGracePeriodSeconds Seconds the pod needs to terminate gracefully 60

Specify each parameter using the --set key=value[,key=value] argument to helm install. For example:

helm install discourse oci://ghcr.io/anatoly-lab/helm-charts/discourse \
  --set discourse.hostname=forum.example.com \
  --set discourse.database.host=postgres.svc.cluster.local

Alternatively, provide a YAML file with the values using -f:

helm install discourse oci://ghcr.io/anatoly-lab/helm-charts/discourse -f my-values.yaml

Plugins and Site Settings

All plugins used in this chart ship with Discourse core -- no third-party plugins need to be cloned into the image. Plugins are enabled or disabled via Discourse site settings, which can be controlled in two ways: through the admin UI, or through environment variables.

Setting site settings via environment variables

Discourse supports a feature called "shadowed settings" -- any site setting can be overridden by an environment variable following the convention DISCOURSE_<UPPERCASE_SETTING_NAME>. When set via env var, the setting is locked at boot time and hidden from the admin UI. This is intentional and useful for GitOps workflows where you want configuration to be declarative and immutable.

Plugin enable/disable env vars

Plugin Env Var Default
Chat DISCOURSE_CHAT_ENABLED true
Solved DISCOURSE_SOLVED_ENABLED true
Assign DISCOURSE_ASSIGN_ENABLED false
OIDC DISCOURSE_OPENID_CONNECT_ENABLED false
Topic Voting DISCOURSE_TOPIC_VOTING_ENABLED true
AI DISCOURSE_DISCOURSE_AI_ENABLED false
Automation DISCOURSE_DISCOURSE_AUTOMATION_ENABLED false
Reactions DISCOURSE_DISCOURSE_REACTIONS_ENABLED false
Poll DISCOURSE_POLL_ENABLED true

Use discourse.extraEnv in your values file to set these:

discourse:
  extraEnv:
    - name: DISCOURSE_CHAT_ENABLED
      value: "true"
    - name: DISCOURSE_ASSIGN_ENABLED
      value: "true"

Why the double DISCOURSE_ prefix?

Some plugins namespace their settings internally with a discourse_ prefix. For example, the AI plugin registers its enabled setting as discourse_ai_enabled. When you apply the DISCOURSE_ env var convention on top of that, you get DISCOURSE_DISCOURSE_AI_ENABLED. This affects the AI, Automation, and Reactions plugins.

OIDC configuration

OpenID Connect (Keycloak, Auth0, any OIDC provider) has a first-class discourse.oidc.* block — see OIDC / SSO above. Use that block rather than extraEnv; it renders the non-secret settings into the ConfigMap and wires the client secret through the chart's secret machinery. Don't set the same DISCOURSE_OPENID_CONNECT_* var via both discourse.oidc and extraEnv (last-wins/undefined).

Admin UI alternative

If you prefer manual control, skip the env vars entirely and toggle plugins through the Discourse admin UI at /admin/site_settings. Settings configured in the admin UI are stored in the database and can be changed at any time without redeploying.

Building the Custom Image

The docker/Dockerfile builds a production-ready Discourse image in six steps:

  1. Starts from discourse/base:slim -- Official Discourse base image with Ruby, Node, pnpm, ImageMagick, and PostgreSQL client. No bundled database or Redis.
  2. Pins the Discourse release -- Checks out the exact release tag (e.g. v2026.3.0-latest) from the Discourse repo already cloned in the base image.
  3. Installs Ruby gems -- adds the sigdump diagnostics gem to the bundle (require: false, so it stays inert until diagnostics.sigdump.enabled opts in), then bundle install --deployment with exact Gemfile.lock versions. rbtrace is already in Discourse core's Gemfile. Also drops a Rails initializer (config/initializers/discourse_k8s_diagnostics.rb) that requires these gems when the chart's diagnostics env vars are set -- loaded in-app after Bundler.setup (a bundled gem can't be loaded via RUBYOPT, which preloads before the bundle is on the load path).
  4. Installs JavaScript dependencies -- pnpm install --frozen-lockfile (or yarn, for forward compat).
  5. Precompiles assets -- bundle exec rake assets:precompile with SKIP_DB_AND_REDIS=1 so no live database is needed during the build.
  6. Freezes version info and drops .git -- bakes config/git-utils-overrides.json so Discourse reports its version without shelling out to git, then rm -rf .git. This fixes a staff-only request freeze and shrinks the image ~122 MB. See Version reporting and the request-freeze fix below.

The image defaults to running Pitchfork (Discourse's unicorn successor) as the web server. The same image is used for the sidekiq container and the migrate init container with different commands.

Version reporting and the request-freeze fix

The final build step bakes a config/git-utils-overrides.json file and then deletes the .git directory. This is both a correctness fix and a performance fix.

The problem. Discourse's GitUtils shells out to the git binary at runtime to report its version and to check commits. One of those calls -- GitUtils.has_commit?, reached when an admin/staff user's homepage is rendered (CurrentUserSerializer#has_unseen_featuresDiscourseUpdates.new_features) -- runs git merge-base --is-ancestor <sha> HEAD. On a worker whose page cache has gone cold (e.g. after the pod sat idle), that subprocess has to cold-read the ~122 MB shallow .git and fork from a large worker process, parking the request for ~14 seconds with zero CPU. It only affects staff (the call is staff-gated) and only fires for cached "what's new" entries whose version is a full commit SHA, so it shows up as "an admin's first page load after idle is slow," never a whole-forum outage.

The fix. This is an immutable image -- we rebuild it per release and never git pull -- so the runtime .git is dead weight. The build:

  1. Writes config/git-utils-overrides.json with git_version (the real HEAD SHA), git_branch, and full_version. Discourse core reads this file (GitUtils.filesystem_overrides) and uses it instead of shelling out for version reporting.
  2. Removes .git. With no repository present, has_commit?'s git merge-base fails instantly (fatal: not a git repository, exit 128); GitUtils swallows the error and returns false. No subprocess pack-read, no freeze.

Note: the overrides file does not cover has_commit? (it calls git directly), so removing .git is the part that actually kills the freeze -- the overrides file is what keeps the admin Version panel accurate afterwards.

The -customized version suffix. full_version is set to <release-tag>-customized (e.g. v2026.6.0-latest-customized). The -customized marks this as the discourse-k8s build rather than stock upstream Discourse, and it's what you'll see in Admin → What's new / Version. (Upstream's own git describe --dirty would otherwise always append -dirty here, because the build edits a tracked file -- it adds gem "sigdump" to the Gemfile -- on every build; a constant -dirty is just noise.)

Build arguments

Arg Default Description
BASE_IMAGE discourse/base:slim Base image providing Ruby, Node, and system dependencies
DISCOURSE_VERSION v2026.3.0-latest.1 Discourse release tag to check out and build
PG_MAJOR 18 PostgreSQL client version to install (must be >= your PG server major, or pg_dump refuses to back up)

Build examples

# Default build
docker build -t my-discourse:latest docker/

# Pin a specific Discourse version
docker build \
  --build-arg DISCOURSE_VERSION=v2026.3.0-latest \
  -t my-discourse:v2026.3.0 docker/

Adding third-party plugins

All bundled Discourse plugins (chat, solved, assign, AI, etc.) are already present in the source tree under plugins/. To add a third-party plugin not included in Discourse core, clone it before the asset precompilation step by adding a RUN instruction to the Dockerfile:

# Add before the "Precompile assets" step
RUN cd plugins && \
    sudo -u discourse git clone --depth 1 https://github.com/org/discourse-my-plugin.git

Plugins must be present before assets:precompile because they contribute JavaScript and CSS to the compiled Ember bundle.

Docker Image Environment Variables

These environment variables are set in the Dockerfile as runtime defaults:

Name Description Value
RAILS_ENV Rails environment production
UNICORN_SIDEKIQS Number of Sidekiq processes to spawn from the web server (set to 0 because Sidekiq runs as a separate container) 0
UNICORN_BIND_ALL Listen on 0.0.0.0 instead of 127.0.0.1 (required inside a container) true
UNICORN_WORKERS Number of Pitchfork worker processes 3
DISCOURSE_SERVE_STATIC_ASSETS Serve static assets directly from Pitchfork (no nginx in front) true

Build arguments

Name Description Default Value
BASE_IMAGE Base image used as the build foundation discourse/base:slim
DISCOURSE_VERSION Discourse release tag to check out from the repository v2026.3.0-latest.1
PG_MAJOR PostgreSQL client version (must be >= your PG server major for backups to work) 18

CI/CD

Both the Docker image and Helm chart are built and published by GitHub Actions, triggered by tags.

Docker image

  • Trigger: Push a tag matching docker/v* (e.g. docker/v0.3.0)
  • Registry: ghcr.io/anatoly-lab/discourse-k8s
  • Tags produced: Version tag + latest
  • Workflow: .github/workflows/docker.yml
git tag docker/v0.3.0
git push origin docker/v0.3.0

Note: The DISCOURSE_VERSION (the Discourse release being built) is pinned in the Dockerfile itself, not passed from CI. The docker/v* tag versions the image, not Discourse. To build a different Discourse release, update the DISCOURSE_VERSION ARG in the Dockerfile.

Helm chart

  • Trigger: Push a tag matching chart/v* (e.g. chart/v0.2.0)
  • Registry: oci://ghcr.io/anatoly-lab/helm-charts
  • Workflow: .github/workflows/helm.yml
git tag chart/v0.2.0
git push origin chart/v0.2.0

Consuming the chart from GHCR

# Pull and inspect
helm pull oci://ghcr.io/anatoly-lab/helm-charts/discourse --version 0.2.0

# Install directly
helm install discourse oci://ghcr.io/anatoly-lab/helm-charts/discourse \
  --version 0.2.0 -f my-values.yaml -n discourse --create-namespace

ArgoCD (multi-source pattern)

spec:
  sources:
    - repoURL: https://github.com/anatoly314/<infra-repo>.git
      targetRevision: HEAD
      ref: values
    - repoURL: ghcr.io/anatoly-lab/helm-charts
      chart: discourse
      targetRevision: 0.2.0
      helm:
        valueFiles:
          - $values/apps/discourse/values.yaml

Included Plugins

Plugin Description
discourse-openid-connect OIDC SSO (Keycloak, Auth0, etc.)
discourse-topic-voting Category-level feature voting
discourse-chat Real-time chat channels, DMs, threads
discourse-solved Mark replies as accepted solutions
discourse-ai AI-powered features (summarize, classify)
discourse-assign Assign topics/posts to staff
discourse-automation Automate actions via triggers
discourse-poll Polls in posts
discourse-reactions Post reactions beyond simple likes
discourse-checklist Checkbox lists in posts
discourse-details Collapsible detail/summary blocks
discourse-narrative-bot New user tutorial bot
discourse-presence "User is typing" indicators
discourse-styleguide Admin style guide for theming

All of these ship with Discourse core under the plugins/ directory. They do not need to be cloned or installed -- they are already present in the source tree. Enable or disable them via environment variables (see Plugins and Site Settings) or through the admin UI.

Troubleshooting

Pod stuck in CrashLoopBackOff on first boot

First boot runs database migrations in the migrate init container, which can take a few minutes. Check the init container logs:

kubectl logs -f deploy/discourse -c migrate -n discourse

If migrations completed but the web container is slow to start, the startup probe allows up to ~10 minutes (failureThreshold: 60 * periodSeconds: 10s):

kubectl logs -f deploy/discourse -c web -n discourse

PostgreSQL connection refused

The wait-for-postgres init container polls PostgreSQL before migrations run. Verify your discourse.database.host and discourse.database.port values. Ensure the database exists and the user has permission to create extensions (pg_trgm, unaccent).

Assets not loading / blank page

If you see unstyled HTML, assets were not precompiled. The pre-built image from GHCR has assets baked in. If you built your own image, verify the rake assets:precompile step completed successfully during the Docker build.

Redis connection errors in logs

Redis runs as a native sidecar init container at localhost:6379. If you see connection errors, check that the Redis container is running:

kubectl get pods -n discourse -o wide
kubectl logs deploy/discourse -c redis -n discourse

Admin account not created

The create-admin init container only runs when discourse.admin.existingSecret or discourse.admin.password is set. Check the init container logs:

kubectl logs deploy/discourse -c create-admin -n discourse

The admin email defaults to the first entry in discourse.developerEmails unless discourse.admin.email is explicitly set. The admin is only created on the very first startup with an empty database -- subsequent boots skip if the user already exists.

Kubernetes version too old for native sidecar

The Redis container uses restartPolicy: Always on an init container, which is a native sidecar feature requiring Kubernetes 1.28 or later. On older clusters, the pod will fail to schedule. Upgrade your cluster or refactor the deployment to use a regular sidecar container (but note that this breaks the migration ordering guarantee).

Sidekiq not processing jobs

Check the sidekiq container logs and verify it started with the correct queues:

kubectl logs deploy/discourse -c sidekiq -n discourse

The chart passes all four Discourse queues (critical, default, low, ultra_low) explicitly. Without these flags, standalone Sidekiq only processes the default queue.

Diagnosing a request freeze

When a web request hangs with zero CPU (a worker parked in a blocking syscall) and you need the Ruby file:line where it's stuck, use the diagnostics toggles. External tracers (strace, gdb --pid) don't work here because the cluster runs ptrace_scope=2 — both tools below are in-process and ptrace-free. They are scoped to the web container and survive pitchfork's mold→worker fork (each worker is addressable by its own pid).

Each toggle sets an env var (DISCOURSE_DIAG_RBTRACE / DISCOURSE_DIAG_SIGDUMP) that a Rails initializer baked into the image reads and requires the gem from. The initializer runs after Bundler has set up the bundle, so the bundled gem loads. (Loading via RUBYOPT does not work: its preloads run at Ruby interpreter startup, before Bundler puts the bundle on the load path, so a bundled gem isn't loadable and the worker crashes at boot.) This needs image tag v0.10.0 or newer; on an older image the toggle is inert.

Start with sigdump. It works on a stock cluster (pure signal + file dump). rbtrace is best-effort here: its SysV message queue won't attach unless the node's kernel.msgmax/kernel.msgmnb are raised (verified failing on a stock node with msgmax=8192/msgmnb=16384), and rbtrace 0.5.3 is fragile on Ruby 3.4.

Enable sigdump (and ensure the image is v0.10.0+). With strategy: Recreate and a single replica, the helm upgrade itself rolls the pod, so the new env var takes effect once the new pod is Ready:

helm upgrade discourse oci://ghcr.io/<owner>/helm-charts/discourse \
  -n discourse --reuse-values \
  --set image.tag=v0.10.0 \
  --set diagnostics.sigdump.enabled=true
kubectl rollout status deploy/discourse -n discourse

…then capture (below). When you're done, disable it again so the gem unloads:

helm upgrade discourse oci://ghcr.io/<owner>/helm-charts/discourse \
  -n discourse --reuse-values --set diagnostics.sigdump.enabled=false

List the pitchfork processes — a mold plus N workers — and pick the pid(s) to inspect (you often won't know which worker holds the stuck request, so be ready to check several):

POD=$(kubectl get pod -n discourse -l app.kubernetes.io/name=discourse -o name | head -1)
kubectl exec -n discourse "$POD" -c web -- pgrep -af pitchfork

sigdump (recommended — works on a stock cluster). Send the default signal (SIGCONT, which pitchfork does not handle, so it's safe) to a worker; it dumps to /tmp/sigdump-<pid>.log inside the container. Signalling every pitchfork pid at once is also fine (each writes its own file):

# Signal one worker, or all of them, then read the dumps:
kubectl exec -n discourse "$POD" -c web -- kill -CONT <WORKER_PID>
kubectl exec -n discourse "$POD" -c web -- pkill -CONT -f pitchfork   # all at once (alternative)
kubectl exec -n discourse "$POD" -c web -- sh -c 'tail -n +1 /tmp/sigdump-*.log'

rbtrace (best-effort — needs node-level SysV tuning, see the note below; run inside the web container via bundle exec so the gem resolves). Pass a concrete pid from the list above:

# Backtraces for every thread of a worker (best signal for a freeze):
kubectl exec -it -n discourse "$POD" -c web -- bundle exec rbtrace -p <WORKER_PID> --backtraces

# Watch for any method call slower than 1000ms while you reproduce the freeze:
kubectl exec -it -n discourse "$POD" -c web -- bundle exec rbtrace -p <WORKER_PID> --slow=1000

# Evaluate an arbitrary expression in the target (e.g. raw thread backtraces):
kubectl exec -it -n discourse "$POD" -c web -- bundle exec rbtrace -p <WORKER_PID> -e 'Thread.list.map(&:backtrace)'

rbtrace uses a SysV message queue (msgget), not ptrace. It needs the node's kernel.msgmax/kernel.msgmnb raised to attach — on a stock node (msgmax=8192, msgmnb=16384) the attach fails with "pid is not listening for messages" even though the gem is loaded. These are namespaced ("unsafe") sysctls, so allowing them needs kubelet --allowed-unsafe-sysctls=kernel.msg* plus a pod securityContext.sysctls entry. If you can't tune the node, use sigdump.

Turn the toggles back off (and let the pod roll) once you've captured what you need.

References

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages