Skip to content

Self-hosting

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

Raw

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.

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
  • kubectl connected 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.

ChartAppwrite
1.0.02.3.0
2.0.02.5.0

To see which Appwrite version the latest chart deploys:

Bash
helm show chart oci://ghcr.io/appwrite/charts/appwrite

Try 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

Bash
kind create cluster --name appwrite

2. Install an ingress controller

Bash
helm install traefik traefik --repo https://traefik.github.io/charts \
--namespace traefik --create-namespace \
--set service.type=ClusterIP --wait

3. Install Appwrite

Bash
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 15m

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

Bash
kubectl -n traefik port-forward svc/traefik 80:80

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

Bash
kind delete cluster --name appwrite

Install 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, called letsencrypt in 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:

SettingWhy it's hard to change later
Release name, or fullnameOverrideEvery 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 keyThe 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.domainIt 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 enginePostgreSQL, 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.devices3, 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 modeReadWriteOnce 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

YAML
# values.yaml
appwrite:
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: true

appwrite.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

Bash
helm install appwrite oci://ghcr.io/appwrite/charts/appwrite \
--namespace appwrite --create-namespace \
-f values.yaml --wait --timeout 15m

The 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

Bash
kubectl -n appwrite get pods
helm test appwrite -n appwrite
kubectl -n appwrite get certificate

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

Bash
kubectl -n appwrite get secret appwrite -o jsonpath='{.data._APP_OPENSSL_KEY_V1}' | base64 -d

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

Bash
helm show values oci://ghcr.io/appwrite/charts/appwrite

The 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.

ServiceBy defaultBring your ownSwitch with
PostgreSQLBuilt-in StatefulSetAmazon RDS and Aurora, Cloud SQL, Azure Database for PostgreSQL, CloudNativePGdatabase.mode
RedisBuilt-in StatefulSetElastiCache, Memorystore, Azure Cache for Redis, Valkeyredis.mode
ClickHouseBuilt-in StatefulSetClickHouse Cloud, the Altinity operatorclickhouse.mode
File storageBuilt-in object storage (SeaweedFS)AWS S3, Cloudflare R2, Google Cloud Storage, DigitalOcean Spaces, MinIO, and other S3-compatible stores; or a PersistentVolumeClaimstorage.s3.mode, storage.device
EmailOffAny SMTP serversmtp

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:

Bash
kubectl create namespace appwrite
kubectl -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:

YAML
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-password

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

YAML
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-key

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

YAML
appwrite:
config:
_APP_VCS_GITHUB_APP_NAME: my-appwrite
secretConfig:
_APP_LOGGING_CONFIG: sentry://...
extraEnvFrom:
- secretRef:
name: appwrite-integrations

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

YAML
smtp:
host: smtp.example.com
port: 587
username: appwrite
password:
secretName: appwrite-external # reads the key smtp-password
appwrite:
system:
emailAddress: appwrite@example.com

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

HostnamePathGoes to
appwrite.domain/v1/realtimeRealtime (WebSocket)
appwrite.domain/v1, /.well-knownAPI
appwrite.domainEverything elseConsole
*.functions.<domain>, *.sites.<domain>EverythingAPI, for function and site previews
Any other hostnameEverythingAPI, 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:

YAML
httpRoute:
enabled: true
parentRefs:
- name: public
namespace: gateway
sectionName: https-appwrite

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

ModeDeploymentsWhen to use
combined (default)One worker Deployment that consumes every queueSmall installs. Uses the least memory.
separateOne Deployment per queueScale or size each queue on its own. Matches the separate topology of the Docker installation.
groupedOne Deployment per group in workers.groupsIsolate busy queues, such as usage or builds, without running a pod per queue.
YAML
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 local storage.
  • 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:

Bash
kubectl label node <node> workload=executor
kubectl taint node <node> workload=executor:NoSchedule

Then pin the executor to them:

YAML
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: true

Without 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: false if 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.existingSecret and each credential's secretName.
  • 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_NUM from 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_CPUS and _APP_COMPUTE_MEMORY in appwrite.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:

Bash
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.com

Upgrading

Each chart version deploys one Appwrite version, so you upgrade Appwrite by upgrading the chart:

  1. Back up the database and the chart's Secret. Appwrite migrations can't be rolled back.
  2. Read the version notes of every chart version in between, and the Appwrite release notes for breaking API changes.
  3. Run the upgrade:
Bash
helm upgrade appwrite oci://ghcr.io/appwrite/charts/appwrite \
--version <chart version> -n appwrite -f values.yaml --wait --timeout 30m

When 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 local and s3 during the move breaks the file paths stored in the database. A Docker installation stores files locally by default, so set storage.device: local, and with it compute.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:

  1. Collect your settings from .env. Put _APP_OPENSSL_KEY_V1, _APP_NOTIFICATIONS_TRACKING_SECRET, and _APP_GEO_SECRET in a Secret and name it in appwrite.secrets.existingSecret. The encryption key must be identical, or encrypted data can't be read.
  2. Stop the Docker installation, dump PostgreSQL, and copy the file volumes.
  3. Install the chart with the API scaled to zero, so nothing writes to the new database.
  4. Restore the database and the files.
  5. 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

Bash
helm uninstall appwrite -n appwrite

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

Bash
kubectl -n appwrite delete secret appwrite
kubectl -n appwrite delete pvc -l app.kubernetes.io/instance=appwrite

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

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.