Kubernetes_
Install self-hosted Appwrite on Kubernetes with the official Helm chart, using built-in or managed PostgreSQL, Redis, ClickHouse, and S3 storage.
15 min read
The official Appwrite Helm chart installs self-hosted Appwrite on your own Kubernetes cluster, and upgrades it with Helm. It deploys the API, console, realtime, workers, and schedulers, plus the routes your ingress controller or Gateway needs to reach them.
A plain install includes everything Appwrite needs, PostgreSQL, Redis, and ClickHouse included, so you can try it on an empty cluster. For production, you replace each of those with a managed service, one at a time.
The chart is published to oci://ghcr.io/appwrite/charts/appwrite, and its source, guides, and example values files live in the appwrite/helm-chart repository.
The chart follows semantic versioning: only a new major version changes or renames values. Read the version notes before each upgrade.
Docker or Kubernetes
The Docker installation runs every Appwrite service on one server, and is the simpler choice when one server is enough. The Helm chart runs the same images on Kubernetes. Choose it when you already operate Kubernetes and want:
- Managed PostgreSQL, Redis, ClickHouse, and S3 in place of containers you maintain
- API, console, and realtime replicas spread across nodes, with autoscaling
- Rolling upgrades, with Appwrite's data migration run as part of
helm upgrade - GitOps deployments with Argo CD or Flux
Functions and Sites are on by default. The chart keeps code and build output in its built-in object storage and runs the executor as a privileged pod, in a namespace of its own. On clusters that don't allow privileged pods, turn them off. See Functions and Sites.
Requirements
- Kubernetes 1.29 or later, with a default StorageClass
- Helm 4, or Helm 3.18 or later
kubectlconnected to the cluster- About 2 CPUs and 4 GiB of memory free for Appwrite, plus room for builds and executions
- An ingress controller, or a Gateway API implementation
- For Functions and Sites, a cluster that admits a privileged pod in the executor's namespace
The console and the API share one hostname. Requests to /v1 go to the API, and everything else goes to the console. An ingress controller or Gateway splits that traffic. A kubectl port-forward to the console alone doesn't work, because the console would send its API calls to itself.
Chart and Appwrite versions
Each chart version deploys one Appwrite version, its appVersion, so upgrading the chart upgrades Appwrite.
| Chart | Appwrite |
|---|---|
| 1.0.0 | 2.3.0 |
| 2.0.0 | 2.5.0 |
To see which Appwrite version the latest chart deploys:
helm show chart oci://ghcr.io/appwrite/charts/appwriteTry it locally
This walkthrough runs Appwrite on kind, which runs a Kubernetes cluster inside Docker, with Traefik as the ingress controller. It uses plain HTTP and takes about ten minutes. k3d, k3s, and Rancher Desktop already include Traefik, so skip step 2 there.
1. Create a cluster
kind create cluster --name appwrite2. Install an ingress controller
helm install traefik traefik --repo https://traefik.github.io/charts \ --namespace traefik --create-namespace \ --set service.type=ClusterIP --wait3. Install Appwrite
helm install appwrite oci://ghcr.io/appwrite/charts/appwrite \ --namespace appwrite --create-namespace \ --set appwrite.domain=appwrite.localhost \ --set appwrite.certificatesEmail=you@example.com \ --set appwrite.forceHttps=false \ --set ingress.enabled=true \ --wait --timeout 15mNothing terminates TLS on a local cluster, so appwrite.forceHttps=false turns off the HTTPS redirect. The first start pulls about 2 GB of images and creates the database schema, which takes a few minutes.
4. Open the console
Forward Traefik's port 80 to your machine:
kubectl -n traefik port-forward svc/traefik 80:80Open http://appwrite.localhost and sign up. The first account becomes the console administrator, and everyone after it needs an invitation. Point your SDKs at http://appwrite.localhost/v1.
Chrome and Firefox resolve *.localhost to your machine. If your browser or SDK doesn't, add 127.0.0.1 appwrite.localhost to your hosts file. Forward port 80 rather than another port, because Appwrite leaves the port out of the links it generates, such as email links and OAuth redirects. On Linux, binding port 80 usually needs sudo.
When you're done, delete the cluster:
kind delete cluster --name appwriteInstall on a cluster
Before you start
- An ingress controller with an IngressClass, or a Gateway API implementation
- cert-manager with a Let's Encrypt
ClusterIssuer, calledletsencryptin the examples below. HTTP-01 is enough for the main domain. - A DNS record for your domain, for example
appwrite.example.com, pointing at the ingress controller's load balancer
Decide before the first install
These settings are hard to change once Appwrite holds data:
| Setting | Why it's hard to change later |
|---|---|
Release name, or fullnameOverride | Every resource name starts with it, including the Secret and the volumes. A new name is a new install, with new credentials and empty volumes. |
| Encryption key | The chart generates _APP_OPENSSL_KEY_V1 once. Without it, a database backup can't be read. Back it up after installing, or create it yourself and pass it through appwrite.secrets.existingSecret. |
appwrite.domain | It ends up in OAuth redirect URLs, Git webhooks, the CNAME target of custom domains, and the preview domains stored in the database. Changing it breaks all of them. |
| Database engine | PostgreSQL, built-in or external, is recommended and is the engine the chart's CI tests. Moving to another engine later is a data migration, not a values change. |
storage.device | s3, the default, keeps files in a bucket: the chart's built-in object storage or your own. local keeps them on a volume. Files stay where they were written, so switching later leaves them behind. Functions and Sites need s3. |
/storage access mode | ReadWriteOnce keeps every pod that mounts /storage on one node. ReadWriteMany lets them spread across nodes. A claim's access mode can't be changed, only replaced with a new claim. |
1. Write a values file
# values.yamlappwrite: domain: appwrite.example.com certificatesEmail: ops@example.com # Let's Encrypt account for custom domains
ingress: enabled: true className: traefik # your IngressClass annotations: cert-manager.io/cluster-issuer: letsencrypt tls: enabled: trueappwrite.forceHttps is on by default, so Appwrite redirects HTTP to HTTPS and generates https:// links. Keep ingress.tls.enabled on with it, or the redirect points at an HTTPS endpoint nothing serves.
The chart's repository has example values files to start from, including overlays for managed services on AWS, Google Cloud, and Azure.
2. Install the chart
helm install appwrite oci://ghcr.io/appwrite/charts/appwrite \ --namespace appwrite --create-namespace \ -f values.yaml --wait --timeout 15mThe chart checks your values before it installs anything, and stops with a message that names any setting that's missing or inconsistent.
3. Check the install
kubectl -n appwrite get podshelm test appwrite -n appwritekubectl -n appwrite get certificateEvery pod should be Running, the three tests should pass, and the certificate should become Ready within a minute or two.
4. Create the first account
Open https://appwrite.example.com and sign up. The first account becomes the console administrator. Everyone after it needs an invitation, unless you change appwrite.console.whitelistRoot.
5. Back up the encryption key
The chart generated _APP_OPENSSL_KEY_V1, which encrypts data at rest. Store it somewhere outside the cluster now:
kubectl -n appwrite get secret appwrite -o jsonpath='{.data._APP_OPENSSL_KEY_V1}' | base64 -dThe Secret is named after the release, appwrite in these examples. A release whose name doesn't contain appwrite, such as prod, creates prod-appwrite.
Configuration
You configure the chart through Helm values. To print every value with its default and description:
helm show values oci://ghcr.io/appwrite/charts/appwriteThe values reference lists the same values, grouped by area.
Built-in or external services
By default, the chart runs every service Appwrite depends on. You can replace each one on its own, for example moving PostgreSQL to Amazon RDS while keeping the built-in Redis and ClickHouse.
| Service | By default | Bring your own | Switch with |
|---|---|---|---|
| PostgreSQL | Built-in StatefulSet | Amazon RDS and Aurora, Cloud SQL, Azure Database for PostgreSQL, CloudNativePG | database.mode |
| Redis | Built-in StatefulSet | ElastiCache, Memorystore, Azure Cache for Redis, Valkey | redis.mode |
| ClickHouse | Built-in StatefulSet | ClickHouse Cloud, the Altinity operator | clickhouse.mode |
| File storage | Built-in object storage (SeaweedFS) | AWS S3, Cloudflare R2, Google Cloud Storage, DigitalOcean Spaces, MinIO, and other S3-compatible stores; or a PersistentVolumeClaim | storage.s3.mode, storage.device |
| Off | Any SMTP server | smtp |
The built-in datastores and object storage each run a single replica, without replication or failover. They suit evaluation and small installs. For data you can't afford to lose, use managed services or operators. To run MariaDB, MySQL, or MongoDB instead of PostgreSQL, set database.adapter on an external database.
Every external service takes a mode switch, an external block with its address, and a password. Every password and key in the chart accepts the same three fields, value, secretName, and secretKey. An inline value ends up in your values file, so keep credentials in a Secret instead:
kubectl create namespace appwritekubectl -n appwrite create secret generic appwrite-external \ --from-literal=db-password='<PostgreSQL password>' \ --from-literal=redis-password='<Redis password>'Then point the chart at the Secret in your values file:
database: mode: external external: host: appwrite-db.example.internal password: secretName: appwrite-external # reads the key db-password
redis: mode: external external: host: appwrite-redis.example.internal password: secretName: appwrite-external # reads the key redis-passwordAn external PostgreSQL needs the postgis, vector, and pg_trgm extensions and a direct connection, not a transaction-mode pooler. Redis needs a single endpoint without TLS. The external databases guide has the setup for each provider, and examples/external-services.yaml annotates every block.
S3 storage
By default, files, deployment code, and build output go to the chart's built-in object storage: SeaweedFS as a single-replica StatefulSet that creates its bucket and generates its keys. For production, point the chart at a store with redundancy:
storage: device: s3 s3: mode: external external: endpoint: https://<account-id>.r2.cloudflarestorage.com # empty for AWS S3 region: auto bucket: appwrite accessKeyId: secretName: appwrite-s3 # reads the key s3-access-key secretAccessKey: secretName: appwrite-s3 # reads the key s3-secret-keyAppwrite doesn't create the bucket in your store, and it signs requests with static keys. IAM roles for service accounts, such as IRSA, EKS Pod Identity, and GKE Workload Identity, aren't supported. With S3, the API and workers still mount a small /storage volume for TLS certificates and ACME challenges. The storage guide lists the endpoint and region for each provider.
Appwrite settings and email
Common settings have values of their own, such as appwrite.domain, appwrite.console.whitelistEmails, and smtp. Set any other environment variable in appwrite.config. Use appwrite.secretConfig for secrets, which the chart stores in a Secret, or appwrite.extraEnvFrom to load a Secret you manage:
appwrite: config: _APP_VCS_GITHUB_APP_NAME: my-appwrite secretConfig: _APP_LOGGING_CONFIG: sentry://... extraEnvFrom: - secretRef: name: appwrite-integrationsLeave the database, Redis, ClickHouse, and executor connection variables out of appwrite.config. The chart sets them from its own services and credentials, and a copy there would override them.
To send verification, recovery, and invitation emails, add an SMTP server:
smtp: host: smtp.example.com port: 587 username: appwrite password: secretName: appwrite-external # reads the key smtp-password
appwrite: system: emailAddress: appwrite@example.comThe integrations guide covers SMTP providers, Git deployments, console sign-in with OAuth, SMS, and error tracking.
Networking
The chart creates the same routes that Traefik handles in the Docker installation:
| Hostname | Path | Goes to |
|---|---|---|
appwrite.domain | /v1/realtime | Realtime (WebSocket) |
appwrite.domain | /v1, /.well-known | API |
appwrite.domain | Everything else | Console |
*.functions.<domain>, *.sites.<domain> | Everything | API, for function and site previews |
| Any other hostname | Everything | API, for custom domains. Off by default. |
Use ingress with an ingress controller, or httpRoute with the Gateway API. httpRoute creates HTTPRoutes that attach to the Gateway listeners you name in parentRefs, and TLS is configured on those listeners:
httpRoute: enabled: true parentRefs: - name: public namespace: gateway sectionName: https-appwriteFunction and site previews need DNS records for *.functions.<domain> and *.sites.<domain>, and a wildcard certificate, which only a DNS-01 issuer can get. Turn them on with ingress.wildcard.enabled or httpRoute.wildcard.enabled.
Custom domains for projects, functions, and sites need a route that accepts any hostname, through ingress.customDomains.enabled or httpRoute.customDomains.enabled. Set appwrite.domainTargets to your load balancer's hostname and IPs so custom domains verify. On a shared IngressClass, the custom-domains route catches every hostname no other Ingress claims, so give Appwrite an IngressClass or Gateway listeners of its own. The networking guide has the details.
Worker topologies
workers.mode decides how the chart spreads Appwrite's queues across Deployments:
| Mode | Deployments | When to use |
|---|---|---|
combined (default) | One worker Deployment that consumes every queue | Small installs. Uses the least memory. |
separate | One Deployment per queue | Scale or size each queue on its own. Matches the separate topology of the Docker installation. |
grouped | One Deployment per group in workers.groups | Isolate busy queues, such as usage or builds, without running a pod per queue. |
workers: mode: grouped groups: core: queues: [databases, deletes, mails, messaging, notifications, certificates, migrations, webhooks] usage: queues: [stats-usage, stats-resources] replicaCount: 2 compute: queues: [builds, jobs, functions, executions, screenshots]The databases queue applies schema changes in order, so the Deployment that consumes it always runs one replica. In combined mode that Deployment is the only worker, so switch to separate or grouped to scale workers out.
Functions and Sites
Functions and Sites are on by default:
- Storage. Builds and the executor exchange code and build output through a bucket, by default in the built-in object storage. The chart refuses to run them with
localstorage. - Builds run as unprivileged Kubernetes Jobs in a namespace of their own.
- Executions run on the Open Runtimes executor, which starts runtime containers through its own Docker daemon. That daemon runs in a privileged container, which is root on its node, in a namespace of its own.
Where privileged pods aren't allowed, set compute.enabled: false, or run the daemon without privileges through Sysbox: set compute.executor.privileged: false and compute.executor.runtimeClassName: sysbox-runc. With compute off, the console still shows Functions and Sites, but creating one fails.
For production, give the executor dedicated nodes, choose the runtimes you need, and set up wildcard DNS and certificates for previews at *.functions.<domain> and *.sites.<domain>. Label and taint the nodes for the executor:
kubectl label node <node> workload=executorkubectl taint node <node> workload=executor:NoScheduleThen pin the executor to them:
compute: executor: privileged: true # the default: Docker-in-Docker, root on the node nodeSelector: workload: executor tolerations: - key: workload value: executor effect: NoSchedule functionsRuntimes: [node-22, python-3.12, php-8.3] sitesRuntimes: [static-1, node-22] # sites build with node-22
ingress: wildcard: enabled: true annotations: cert-manager.io/cluster-issuer: letsencrypt-dns01 # a DNS-01 ClusterIssuer tls: enabled: trueWithout nodes that match the selector, the executor pod stays Pending.
The executor runs in a namespace of its own and can't read Secrets in the Appwrite namespace. When the S3 keys are generated by the chart or set inline, the chart copies them over. When they come from a Secret you manage, create a copy for the executor, as the executor guide shows. Runtime options that the Docker installation sets with environment variables are covered in Functions and Sites.
Production checklist
Before real users depend on the install:
- Move PostgreSQL, Redis, and ClickHouse to managed services or operators, with replication and backups, and files to an S3 store with redundancy.
- Give the executor dedicated nodes, or set
compute.enabled: falseif you don't use Functions and Sites. - Back up the chart's whole Secret outside the cluster, not only the encryption key, because the built-in PostgreSQL's password is tied to its volume. Or provide every credential from your secret manager with
appwrite.secrets.existingSecretand each credential'ssecretName. - Back up the database and files, and test a restore.
- Use a ReadWriteMany volume for
/storage, so API and worker pods can spread across nodes. - Set resources on every component. The chart sets
_APP_CPU_NUMfrom each pod's CPU limit, and Appwrite sizes its processes and database pools from it. - Run several API, console, and realtime replicas, or turn on their autoscaling.
- Keep the database connections of every Appwrite pod at peak, counting autoscaling maximums, below the database's
max_connections. - Turn on NetworkPolicies with
networkPolicy.enabled, and limit the entry points to your ingress controller. - Cap function and site specifications with
_APP_COMPUTE_CPUSand_APP_COMPUTE_MEMORYinappwrite.config, if you run Functions and Sites. - Monitor pod restarts, queue backlogs, and Appwrite's health endpoints. Workers and schedulers have no probes, so a hung worker shows up only as a growing queue.
The chart's production guide explains each item, and examples/production.yaml puts them together.
Security
Every pod in the Appwrite namespace runs as a non-root user with a read-only root filesystem, no Linux capabilities, and the RuntimeDefault seccomp profile, so the restricted Pod Security Standard admits it. Build Jobs run in their own namespace under baseline, and the executor in its own namespace under privileged.
Released charts are signed with cosign keyless signing from GitHub Actions, and carry a build provenance attestation. Verify a chart version before you install it, with cosign 3 or later:
cosign verify ghcr.io/appwrite/charts/appwrite:<version> \ --certificate-identity https://github.com/appwrite/helm-chart/.github/workflows/release.yaml@refs/heads/main \ --certificate-oidc-issuer https://token.actions.githubusercontent.comUpgrading
Each chart version deploys one Appwrite version, so you upgrade Appwrite by upgrading the chart:
- Back up the database and the chart's Secret. Appwrite migrations can't be rolled back.
- Read the version notes of every chart version in between, and the Appwrite release notes for breaking API changes.
- Run the upgrade:
helm upgrade appwrite oci://ghcr.io/appwrite/charts/appwrite \ --version <chart version> -n appwrite -f values.yaml --wait --timeout 30mWhen the Appwrite version changes, a post-upgrade Job runs Appwrite's data migration, and Helm reports success only after it finishes. Pass --wait, so the migration starts after the new pods are ready. Raise --timeout for large installs: the default of 5 minutes is too short for most migrations.
Each migration run applies only the migration of the version it installs, so don't jump over a version whose migration you haven't run. The chart's upgrade guide lists which versions share a migration, and what to do when one fails.
Migrating from Docker
You can move an existing Docker Compose installation to the chart and keep all of its data. Before you start:
- Upgrade the Docker installation to the Appwrite version the chart deploys, including its data migration.
- Keep the same storage device. Switching between
localands3during the move breaks the file paths stored in the database. A Docker installation stores files locally by default, so setstorage.device: local, and with itcompute.enabled: false, unless it already uses S3. - Plan for downtime. Appwrite is unavailable from step 2 until step 5 finishes.
The move has five steps:
- Collect your settings from
.env. Put_APP_OPENSSL_KEY_V1,_APP_NOTIFICATIONS_TRACKING_SECRET, and_APP_GEO_SECRETin a Secret and name it inappwrite.secrets.existingSecret. The encryption key must be identical, or encrypted data can't be read. - Stop the Docker installation, dump PostgreSQL, and copy the file volumes.
- Install the chart with the API scaled to zero, so nothing writes to the new database.
- Restore the database and the files.
- Upgrade the release with your values to start Appwrite, then move DNS to the cluster.
The chart's migration guide has every command, a table that maps .env variables to chart values, and notes for MariaDB and MongoDB installations.
Uninstalling
helm uninstall appwrite -n appwriteHelm leaves the chart's Secret and the PersistentVolumeClaims of /storage and the datastores behind on purpose, and installing again with the same release name picks them up. To remove everything, delete the Secret and the claims together:
kubectl -n appwrite delete secret appwritekubectl -n appwrite delete pvc -l app.kubernetes.io/instance=appwriteDeleting only one of them leaves you with new passwords for a database that still expects the old ones.
Learn more
The chart's repository has a guide for each part of an install:
- Architecture: components, startup order, configuration flow, and database connection sizing
- Networking, TLS, and domains: Ingress, Gateway API, cert-manager, and custom domains
- External databases: PostgreSQL, MariaDB, MySQL, MongoDB, Redis, and ClickHouse
- Functions and Sites: build Jobs, the executor, and sizing
- GitOps: Argo CD, Flux, and External Secrets
- Backup and restore
- Troubleshooting
Report problems with the chart in the helm-chart repository, and problems in Appwrite itself in appwrite/appwrite.
Was this page helpful?
Share what worked or what we should fix. Once approved, our agents automatically apply suggested updates to the docs.