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.
+----------------------------------------------------------------------------+
| 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-alpinenative 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.
- 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. - wait-for-postgres -- Polls the PostgreSQL host with
ncuntil it responds. - 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. - warm-css -- Runs
bundle exec rake assets:precompile:css, pre-compiling theme and plugin CSS into a sharedemptyDirthat 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 bystylesheetWarmup.enabled-- skipped entirely when disabled. - create-admin -- Creates the admin user if it doesn't exist (first boot only). Uses the email from
discourse.developerEmailsand password fromdiscourse.admin.existingSecret. Idempotent -- skips if the user already exists. Only runs when an admin password is configured.
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.
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.
- Kubernetes 1.28+ -- Required for native sidecar support (
restartPolicy: Alwayson init containers) - External PostgreSQL with
pg_trgmandunaccentextensions (auto-created bydb:migrate, but the user must have permission) - Helm 3
- Docker (only if building the custom image yourself)
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/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;openssl rand -hex 64# 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"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-namespaceFrom a local checkout:
helm install discourse chart/ \
-f my-values.yaml \
--namespace discourse \
--create-namespaceFirst 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 discourseThe 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:3000If 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.
| 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 | "" |
| 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 |
| 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/valuefor simple setups, orexistingSecret+secretKeyto reference a pre-existing Kubernetes Secret (for Vault, ESO, or similar operators).
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.
| 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 |
| 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 |
| 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 |
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=truerolls the web pod (withstrategy: Recreate+ 1 replica, a brief restart) and the gem loads at the new pod's boot. Set it back tofalseand 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 |
| 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 |
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_BUCKETyourself viaextraEnv— it flipsuse_s3?true and welds the compiled assets onto S3, which is exactly what this chart avoids. Use thediscourse.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.
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.*, notextraEnv. Setting the sameDISCOURSE_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| 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-formingress.hosts[]/ingress.tls[]was replaced by a single-host scalar API. Migration:
Old (≤ 0.10.0) New (≥ 0.11.0) ingress.hosts[0].hostderived from discourse.hostname(or setingress.hostto override)ingress.hosts[0].paths[0].pathingress.pathingress.hosts[0].paths[0].pathTypeingress.pathTypeingress.tls[0].secretNameingress.tls.secretName+ingress.tls.enabled: trueingress.tls[0].hosts[0]derived (same $hostas the rule)
| 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 |
| 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) | "" |
| 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.localAlternatively, provide a YAML file with the values using -f:
helm install discourse oci://ghcr.io/anatoly-lab/helm-charts/discourse -f my-values.yamlAll 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.
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 | 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"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.
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).
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.
The docker/Dockerfile builds a production-ready Discourse image in six steps:
- Starts from
discourse/base:slim-- Official Discourse base image with Ruby, Node, pnpm, ImageMagick, and PostgreSQL client. No bundled database or Redis. - 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. - Installs Ruby gems -- adds the
sigdumpdiagnostics gem to the bundle (require: false, so it stays inert untildiagnostics.sigdump.enabledopts in), thenbundle install --deploymentwith exact Gemfile.lock versions.rbtraceis already in Discourse core's Gemfile. Also drops a Rails initializer (config/initializers/discourse_k8s_diagnostics.rb) thatrequires these gems when the chart's diagnostics env vars are set -- loaded in-app after Bundler.setup (a bundled gem can't be loaded viaRUBYOPT, which preloads before the bundle is on the load path). - Installs JavaScript dependencies --
pnpm install --frozen-lockfile(or yarn, for forward compat). - Precompiles assets --
bundle exec rake assets:precompilewithSKIP_DB_AND_REDIS=1so no live database is needed during the build. - Freezes version info and drops
.git-- bakesconfig/git-utils-overrides.jsonso Discourse reports its version without shelling out to git, thenrm -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.
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_features → DiscourseUpdates.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:
- Writes
config/git-utils-overrides.jsonwithgit_version(the real HEAD SHA),git_branch, andfull_version. Discourse core reads this file (GitUtils.filesystem_overrides) and uses it instead of shelling out for version reporting. - Removes
.git. With no repository present,has_commit?'sgit merge-basefails instantly (fatal: not a git repository, exit 128);GitUtilsswallows the error and returnsfalse. No subprocess pack-read, no freeze.
Note: the overrides file does not cover
has_commit?(it calls git directly), so removing.gitis 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.)
| 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) |
# 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/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.gitPlugins must be present before assets:precompile because they contribute JavaScript and CSS to the compiled Ember bundle.
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 |
| 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 |
Both the Docker image and Helm chart are built and published by GitHub Actions, triggered by tags.
- 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.0Note: 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.
- 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# 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-namespacespec:
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| 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.
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 discourseIf 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 discoursePostgreSQL 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 discourseAdmin 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 discourseThe 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 discourseThe chart passes all four Discourse queues (critical, default, low, ultra_low) explicitly. Without these flags, standalone Sidekiq only processes the default queue.
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.msgmnbare raised (verified failing on a stock node withmsgmax=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=falseList 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 pitchforksigdump (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'skernel.msgmax/kernel.msgmnbraised 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 podsecurityContext.sysctlsentry. 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.
- discourse/base Docker Hub -- Base image source
- Discourse environment variables -- Canonical configuration reference
- Discourse -- Discourse project home
MIT