Table of Contents
- outline
- Prerequisites
- Installation
- Configurable values
- Operating notes
- Routing: Ingress or Gateway API
- Why all three probes share /_health
- One replica, one volume
- Two upload ceilings, the smaller one wins
- Rotating a secret does not restart the pod
- The chart configures no mail
- PGSSLMODE=disable
- The egress ports are the standard ones, not your configured ones
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
outline
Deploys Outline, a team knowledge base, as a single-replica StatefulSet with a local upload volume. Postgres and Redis are not part of this chart.
| Image | outlinewiki/outline |
| Versions | not listed on purpose — read the current pair off outline/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) |
| Storage | one RWO PVC for uploads (FILE_STORAGE=local) |
| Routing | two mutually exclusive modes, Ingress (default) or Gateway API (httpRoute.enabled); ingress.domain is required in both — see Routing: Ingress or Gateway API. In Gateway mode the upload cap is not enforced, which moves #12 |
| External dependencies | Postgres, Redis, an OIDC provider |
| Secrets | 3 × OnePasswordItem |
| Install-test | ci/outline/ (fixtures + test values) |
Prerequisites
- 1Password operator in the cluster, plus three 1Password items — see the fields below.
- Postgres and Redis reachable before the first start. All three probes hit
/_health, which checks both, so a missing database shows up as a pod that never turns Ready — read the container log, not the pod events. - cert-manager with the
ClusterIssuernamed iningress.clusterIssuer, and DNS foringress.domainpointing at the ingress controller. In Gateway mode neither is needed from this release: the certificate sits on the Gateway's listener, and what is needed instead is the Gateway-API CRDs plus a Gateway whose listener admits routes from this namespace. - An OIDC client whose redirect URI is
https://<ingress.domain>/auth/oidc.callback.
Installation
The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update
helm install outline jlab/outline \
--namespace outline --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
configSecretRef: op://vault/outline-config
dbSecretRef: op://vault/outline-db
redisSecretRef: op://vault/outline-redis
ingress:
domain: wiki.example.com
clusterIssuer: letsencrypt
The same installation in Gateway mode instead — the Gateway, its class and its certificate belong to the cluster, not to this release:
ingress:
enabled: false
domain: wiki.example.com # still required: it is Outline's URL variable
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https
This needs the Gateway-API CRDs installed and a Gateway whose listener admits routes from this namespace; ingress.clusterIssuer is not used. ingress.domain is still required — it becomes Outline's URL, which builds every generated link, every share URL and the OIDC redirect URI. And ingress.maxUploadFileSize stops being enforced; see Routing: Ingress or Gateway API.
helm install prints the chart's NOTES.txt, which renders the same information against the values actually set. Read it — it is the per-release version of this page.
What the 1Password items must contain
The chart renders OnePasswordItem CRs; the operator turns each into a Kubernetes Secret whose keys are the field names of the 1Password item. A missing field does not fail the install: the release goes through and the pod then hangs in CreateContainerConfigError naming the key it could not find.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.configSecretRef |
<release>-outline-secret |
outline-secret-key (32 random bytes hex, openssl rand -hex 32), outline-utils-secret (a second, independent value), discovery-url (OIDC .well-known endpoint), client-id, client-secret |
secrets.dbSecretRef |
<release>-outline-db-secret |
connection-url — postgres://user:password@host:5432/database |
secrets.redisSecretRef |
<release>-outline-redis-secret |
connection-url — redis://host:6379 |
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders every workload with replicas: 0 without uninstalling. Takes precedence over outline.replicas. PVC, Secrets, Service and Ingress stay. |
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.configSecretRef |
string | null |
Required. op:// path of the app/OIDC item. |
secrets.dbSecretRef |
string | null |
Required. op:// path of the Postgres item. |
secrets.redisSecretRef |
string | null |
Required. op:// path of the Redis item. |
outline
| Key | Type | Default | Description |
|---|---|---|---|
outline.replicas |
int | 1 |
Replica count; 0 is allowed. See One replica, one volume before raising it. |
outline.maxUploadSize |
string | '262144000' |
Outline's own upload ceiling, in bytes (~250 MiB). |
outline.forceHttps |
string | 'false' |
Outline redirects to HTTPS itself. Off by default because the Ingress already terminates TLS; on, it loops endlessly if the forwarded-proto header does not reach the pod. |
outline.oidc.displayName |
string | OIDC |
Label of the login button. |
outline.storage.size |
string | 10Gi |
Size of the upload PVC. |
outline.storage.storageClass |
string | null |
Storage class of the upload PVC; null uses the cluster default. |
outline.securityContext.pod |
object | non-root 1001, fsGroup: 1001, seccompProfile: RuntimeDefault |
Passed verbatim into the pod spec. The seccomp profile sits here on purpose: it covers every container including init containers. |
outline.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation |
Passed verbatim into the container spec. Outline writes only to the PVC and to /tmp (an emptyDir), so the root filesystem stays read-only. |
outline.livenessProbe |
object | GET /_health:3000, period 10s, timeout 5s, 3 failures |
See Why all three probes share /_health. |
outline.readinessProbe |
object | GET /_health:3000, period 10s, timeout 5s, 3 failures |
|
outline.startupProbe |
object | GET /_health:3000, period 10s, 30 failures (= 300s) |
Gates liveness and readiness. The budget covers the startup migrations. |
outline.env |
list | [] |
Extra environment variables appended to the chart's own. Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Additive, not overriding — see Environment variables. |
outline.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
outline.resources.requests |
object | cpu: 0.1, memory: 512Mi, ephemeralStorage: 10Mi |
|
outline.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
ingress
| Key | Type | Default | Description |
|---|---|---|---|
ingress.enabled |
bool | true |
Switches the Ingress object off. Mutually exclusive with httpRoute.enabled. ingress.domain stays required — it is also Outline's URL variable and carries its own required() in the StatefulSet. |
ingress.domain |
string | null |
Required in every mode. Host, and the base of every generated link, share URL and the OIDC redirect URI. |
ingress.clusterIssuer |
string | null |
Required while the Ingress is enabled. cert-manager ClusterIssuer. Unused and unnecessary in Gateway mode. |
ingress.className |
string | nginx |
Ingress class. No counterpart in Gateway mode — the class sits on the Gateway. |
ingress.maxUploadFileSize |
string | 25m |
proxy-body-size annotation. See Two upload ceilings. Not enforced in Gateway mode. |
ingress.annotations |
object | {} |
Merged with the cert-manager and body-size annotations the chart always sets. No counterpart in Gateway mode: the Gateway API has no annotation channel, so these are gone there — the chart's own upload cap included. |
TLS is stored in tls-<release>-outline-ingress.
httpRoute
The Gateway-API alternative to the Ingress, off by default. Added with group 2 of the Gateway-API sweep (Gitea #114); the form is the pilot's and is binding for every chart (style guide §4).
| Key | Type | Default | Description |
|---|---|---|---|
httpRoute.enabled |
bool | false |
Renders <release>-outline-httproute instead of the Ingress. Mutually exclusive with ingress.enabled — with both true the render aborts naming both values. |
httpRoute.parentRefs |
list | [] |
The Gateway(s) the route attaches to — Gateway API ParentReference entries (name, and optionally namespace, sectionName, port, group, kind), passed through unchanged. Required while httpRoute.enabled is true: the render aborts on an empty list. The schema requires name on every entry and is otherwise left open, because Kubernetes owns that schema and adds fields to it across releases. |
httpRoute.hostnames |
list | [] |
Empty means [ingress.domain], so the hostname has a single source of truth. A hostname set here does not move Outline's URL with it — set both together, or the app hands out links under the old name and an OIDC login fails on a redirect_uri the provider does not know. |
What the chart renders in this mode is only the HTTPRoute: one rule, PathPrefix: /, one backendRefs entry pointing at <release>-outline-service on port 80 — the port number, because backendRefs cannot name a port the way the Ingress backend does. One path covers UI, API and the collaborative WebSocket; the upgrade passes through a plain route, with no annotation involved in either mode.
networkPolicy
Inbound 3000 only. Outbound DNS, TCP 5432 (Postgres), 6379 (Redis) and 443 (the OIDC provider's discovery document, fetched at boot).
| Value | Type | Default | Meaning |
|---|---|---|---|
networkPolicy.enabled |
bool | true |
Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. |
networkPolicy.allowedNamespaces |
list | [] |
Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it. |
networkPolicy.probeSources |
list | [] |
CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready. |
networkPolicy.ingressController |
object | kube-system / app.kubernetes.io/name: rke2-traefik |
The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed. |
networkPolicy.egress.extraPorts |
list | [] |
Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. |
Operating notes
Routing: Ingress or Gateway API
Two mutually exclusive modes, and exactly one of them may be on. ingress.enabled ships true, httpRoute.enabled ships false.
| Rule | Why it is a hard failure and not a default |
|---|---|
ingress.enabled and httpRoute.enabled cannot both be true — the render aborts naming both value paths |
two data paths onto one Service under one hostname is a conflict at DNS level already: one name, two addresses, and which one a client reaches is luck. A migration runs over a second hostname (httpRoute.hostnames set explicitly while the Ingress keeps ingress.domain), never in parallel. |
httpRoute.parentRefs must not be empty — the render aborts |
a route without a parent attaches to no Gateway. The object is created, it is schema-valid, no controller rejects it, and it routes nothing: no event, no condition, and the only symptom is a hostname that does not answer. |
| the mode is a value, never auto-detected | .Capabilities.APIVersions is unreliable at template time, and an HTTPRoute without the CRDs fails at install time. A chart that guessed would sometimes render an object the cluster rejects and sometimes silently render nothing. |
What moves to the platform. In Gateway mode the chart renders no Gateway, no gatewayClassName, no Certificate and — deliberately — no implementation-specific policy object (ClientSettingsPolicy, ClientTrafficPolicy and their equivalents). Only HTTPRoute with parentRefs looks identical on nginx-gateway-fabric, Cilium, Envoy Gateway and Istio, and keeping to that subset is what lets the cluster's implementation be chosen, or replaced, without touching the chart again. Class and certificate therefore belong to the Gateway and its listeners, which live in cluster-config. ingress.clusterIssuer is unused and unnecessary in this mode.
ingress.domain is required in this mode too, and not because of the route. It becomes Outline's URL, with its own required() in the StatefulSet — every generated link, every share URL and the OIDC redirect URI come from it. Set httpRoute.hostnames to a name ingress.domain does not carry and the route publishes one host while the app advertises another: pages load, and the OIDC login fails on a redirect_uri the provider does not know.
The upload ceiling becomes the Gateway's, which is why this mode moves the open defect rather than resolving it. ingress.maxUploadFileSize exists only as an ingress-nginx annotation, and the Gateway API has no counterpart — it knows no request-body size at all, that is implementation-specific policy, and this chart deliberately renders no policy object. In Gateway mode the ceiling that actually bites is therefore the smaller of outline.maxUploadSize and whatever the Gateway enforces, and the chart can only name the first of the two. See Two upload ceilings and check the Gateway's own limit before trusting either number.
Whether the route attached at all is only visible in its status, because no Kubernetes object reports a route that attaches to nothing:
kubectl -n <namespace> get httproute <release>-outline-httproute -o yaml
Accepted: True and ResolvedRefs: True under status.parents[] is a working attachment. An empty status means no controller has looked at the route at all — usually a parentRefs pointing at a Gateway that does not exist, or a listener whose allowedRoutes does not admit this namespace.
Why all three probes share /_health
/_health checks Postgres and Redis. Normally liveness must not hang on an external dependency (style guide §12.3), but Outline returns 500 on / as well when the database is down, so there is no dependency-free path to probe. The reason is a comment on the helper — do not "fix" the inconsistency.
One replica, one volume
Uploads live on a ReadWriteOnce PVC that is a plain chart object, not a volumeClaimTemplate. Every replica would mount the same volume, so a second pod usually stays Pending on Multi-Attach error for volume.
Two upload ceilings, the smaller one wins
outline.maxUploadSize (bytes, default ~250 MiB) and ingress.maxUploadFileSize (default 25m). With the defaults the Ingress is tighter by a factor of ten, and it fails the more confusingly: the file never reaches Outline, so the user sees the ingress controller's 413 page rather than a message from the app. Raise both, or neither. This is Gitea #12, still open.
Gateway mode changes the shape of that defect without fixing it. The ingress half of the pair disappears — the Gateway API has no body-size field, so ingress.maxUploadFileSize is simply not enforced — and the effective ceiling becomes the smaller of outline.maxUploadSize and whatever the Gateway enforces. Neither of the two numbers this chart documents is then necessarily the one that decides, which is worse for diagnosis than the factor of ten: at least both of those are in the values file. Rebuilding the limit in the chart would mean rendering an implementation-specific policy object, which is the portability the Gateway mode exists to buy. See Routing.
Rotating a secret does not restart the pod
Helm never sees a OnePasswordItem's content, so it cannot hash it (style guide §9). Pointing a *SecretRef at a different item rolls the pods; editing a value inside the same item does not. After such a rotation:
kubectl -n <namespace> rollout restart statefulset/<release>-outline-sfs
Rotating outline-secret-key invalidates existing sessions, and doing so without keeping the old value makes previously encrypted data unreadable.
The chart configures no mail
No SMTP_* variable is set, so invitations, notifications and digest mails are silently never sent and nothing in the UI says so. Add them under outline.env, which also supports secretKeyRef for the password.
PGSSLMODE=disable
Pinned next to the connection URL. That fits a Postgres reached over the cluster network; anything less trusted needs TLS asked for explicitly, and the chart has no value for it — use outline.env.
The egress ports are the standard ones, not your configured ones
A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination.
Worse, the chart does not even know the port: the connection details arrive inside 1Password items it never parses. So the rules name the conventional ports, and a dependency listening anywhere else is blocked. Put the real port in networkPolicy.egress.extraPorts.
All three probes hit /_health, which checks Postgres and Redis — so a blocked data store here does not show up as a connection error but as a pod that never becomes Ready. That is also what made this chart the one used to prove the install-test enforces policies at all: with its DNS egress rule removed, the CI job failed after 5m59s instead of going green.
The probed port is open inside the cluster by default
networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.
It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.
Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.
The policy is only a boundary where the CNI enforces one
The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.
Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.
Upgrades and backup
- Back up the database before every app-version bump. Migrations are one-way; a
helm rollbackreturns the old image to a database the new one has already rewritten. - Raise
outline.startupProbe.failureThresholdbefore a large jump or a first import, not after the crash loop has started: a restart lands in the middle of a migration. - Backup is two things at once. The database holds documents and one row per attachment, the PVC holds the attachment bytes. Snapshot them together. Because the PVC is a chart object,
helm uninstalldeletes it — the external database survives, which makes an accidental uninstall look recoverable while the attachments are already gone. - The first account to sign in creates the workspace and becomes its admin. On a fresh install, sign in yourself before handing out the URL.
CI
ci/outline/ holds test-values.yaml (op:// refs against the stub CRD, outline.ci.local) and fixtures.yaml (throwaway Postgres and Redis plus the dummy secrets). The install-test runs on every PR that touches the chart — see CI/CD.
The install-test stays in Ingress mode; the route is held at render level. The install runs exactly one configuration per chart and the two modes are exclusive, so one had to be picked. cyberchef is the chart that pays that price and installs in Gateway mode against the real Gateway-API CRDs, which is where a malformed parentRefs or backendRefs is caught. Instead ci/httproute/outline.yaml is a render-only overlay that switches this chart into Gateway mode for ci/run-httproute-match-test.sh, which asserts that every backendRefs entry names a Service and a port the same render creates and that parentRefs is not empty. A chart that has an overlay and renders no route fails that test, so the assertion cannot go vacuum-green. Neither half proves that traffic flows: no Gateway controller runs in CI.
See also
- Chart style guide — the rules this chart implements
- Versioning — what a bump of
7.1.2+up1.10.1means - Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline