Deploying an AI-ready AFFiNE workspace with pgvector on Civo Kubernetes
Deploy AFFiNE, a self-hosted docs and whiteboard workspace, on Civo Kubernetes with a pgvector-enabled PostgreSQL database, Redis caching, and AI copilot support.
Written by
Technical Writer
Written by
Technical Writer
AFFiNE is an open-source workspace tool that combines documents, whiteboards, and knowledge management into a single platform. It provides a self-hosted alternative to proprietary collaboration tools, giving individuals and teams full control over their data while helping reduce software subscription costs.
While AFFiNE can be deployed using Docker Compose on a single server, production environments often require higher availability, easier scaling, and improved reliability. Running AFFiNE on Kubernetes allows workloads to be distributed across multiple nodes while providing automated recovery and simplified infrastructure management.
In this tutorial, we will deploy AFFiNE on a Civo K3s Kubernetes cluster using Kubernetes YAML manifests. The deployment includes the AFFiNE application, Redis for caching, and a Civo Managed PostgreSQL database with pgvector support for persistent storage, resulting in a scalable and production-ready deployment.
Why Affine self-host on Kubernetes
A few reasons teams choose this approach:
- You control your data. Documents, whiteboards, and workspace data stay within your own infrastructure rather than being stored on a third-party platform.
- Lower long-term costs. Self-hosting AFFiNE can be more cost-effective than paying recurring subscription fees, especially for growing teams with multiple users.
- Built for growth. Kubernetes makes it easier to support a larger number of users by distributing workloads across multiple nodes and scaling resources as demand increases.
Prerequisites
Before getting started, make sure you have the following:
- A Civo account
- A running Civo K3s Kubernetes cluster
- The Civo CLI installed and authenticated
- kubectl installed and configured
- A Civo Managed PostgreSQL database
- An LLM API key (required to run the AI Copilot and search features)
- A domain name you control, with the ability to add an A record pointing at your VM. If you don't have a domain, we will use nip.io, a free wildcard DNS service, so any IP gets a working hostname that Caddy can fetch a certificate for
Connect kubectl to the Civo Kubernetes cluster
Before managing any AFFiNE resources on the Civo Kubernetes cluster, ensure that your local kubectl client is connected. This allows you to manage cluster resources and apply Kubernetes manifests from your workstation.
List the available Kubernetes clusters in your Civo account:
civo kubernetes ls
Example output:
+--------------------------------------+-------------------+| ID | Name |+--------------------------------------+-------------------+| ca878c7d-2c2a-43e6-82c0-454005709d3e | cool-sky-65132581 |+--------------------------------------+-------------------+
Download and save the kubeconfig for your cluster:
civo kubernetes config <cluster_name> --save
Replace <cluster_name> with the actual cluster name
Verify that the Kubernetes context has been added successfully:
kubectl config get-contexts
Expected output:
CURRENT NAME* <cluster_name>
If multiple contexts exist on your machine, switch to the AFFiNE cluster context:
kubectl config use-context <cluster_name>
Next, verify that kubectl can communicate with the cluster:
kubectl cluster-info
Expected output:
Kubernetes control plane is running at https://<CLUSTER-ENDPOINT>:6443CoreDNS is running at https://<CLUSTER-ENDPOINT>:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
Finally, confirm that the cluster node is available:
kubectl get nodes
Expected output:
NAME STATUS ROLES AGE VERSIONk3s-cool-sky-65132581-default-pool-xxxx Ready <none> 5m v1.xx.x+k3s1
Once the node reports a Ready status, you're ready to begin deploying AFFiNE resources to the cluster.
Create a namespace for AFFiNE
To keep the deployment organized, we'll create a dedicated working directory for all Kubernetes manifests and then create a separate namespace for the AFFiNE resources.
First, create a project directory and navigate into it:
mkdir -p ~/affine-k8s
cd ~/affine-k8s
Next, create a file named namespace.yaml:
nano namespace.yaml
Add the following content:
apiVersion: v1kind: Namespacemetadata:name: affine
Apply the manifest:
kubectl apply -f namespace.yaml
Expected output:
namespace/affine created
Verify that the namespace was created successfully:
kubectl get namespaces
You should see the affine namespace listed in the output:
NAME STATUSaffine Active
With the namespace in place, we'll deploy all remaining AFFiNE resources into this dedicated namespace.
Deploy PostgreSQL with pgvector inside the Cluster
AFFiNE requires a PostgreSQL database to store user accounts, workspaces, and application data. We will deploy PostgreSQL directly into our Kubernetes cluster using the official pgvectorimage. This ensures our database is fully AI-ready right out of the box.
Create a file named postgres.yaml:
nano postgres.yaml
Add the following manifests. This file creates persistent storage, the PostgreSQL deployment, and an internal service to route traffic:
apiVersion: v1kind: PersistentVolumeClaimmetadata:name: postgres-storagenamespace: affinespec:accessModes:- ReadWriteOncestorageClassName: civo-volumeresources:requests:storage: 10Gi---apiVersion: apps/v1kind: Deploymentmetadata:name: postgresnamespace: affinespec:replicas: 1selector:matchLabels:app: postgrestemplate:metadata:labels:app: postgresspec:containers:- name: postgresimage: pgvector/pgvector:pg16env:- name: POSTGRES_USERvalue: "civo"- name: POSTGRES_PASSWORDvalue: "<YOUR_POSTGRES_PASSWORD>"- name: POSTGRES_DBvalue: "affine"- name: PGDATAvalue: "/var/lib/postgresql/data/pgdata"ports:- containerPort: 5432volumeMounts:- name: postgres-datamountPath: /var/lib/postgresql/datavolumes:- name: postgres-datapersistentVolumeClaim:claimName: postgres-storage---apiVersion: v1kind: Servicemetadata:name: postgresnamespace: affinespec:selector:app: postgresports:- port: 5432targetPort: 5432
Replace:
- <YOUR_POSTGRES_PASSWORD> with your actual password.
Apply the manifest:
kubectl apply -f postgres.yaml
Wait a minute for the pod to start running, then verify it:
kubectl get pods -n affine
Finally, we must turn on the pgvectorextension so the AI Copilot can save vector data. Since the database is running inside our cluster, run this command to execute the SQL query directly inside the pod:
kubectl exec -it deployment/postgres -n affine -- psql -U civo -d affine -c "CREATE EXTENSION IF NOT EXISTS vector;"
Expected output:
CREATE EXTENSION
Configure persistent storage
AFFiNE stores uploaded files, workspace assets, and application data that should persist even if a pod is restarted or rescheduled. To ensure this data is not lost, we'll create a PersistentVolumeClaim (PVC) that uses Civo's default StorageClass.
Before creating the PVC, verify the available StorageClass in the cluster:
kubectl get storageclass
Expected output:
NAME PROVISIONER RECLAIMPOLICYcivo-volume (default) csi.civo.com Delete
Next, create a file named pvc.yaml:
nano pvc.yaml
Add the following configuration:
apiVersion: v1kind: PersistentVolumeClaimmetadata:name: affine-storagenamespace: affinespec:accessModes:- ReadWriteOncestorageClassName: civo-volumeresources:requests:storage: 10Gi
Apply the manifest:
kubectl apply -f pvc.yaml
Expected output:
persistentvolumeclaim/affine-storage created
Verify that the PVC has been created:
kubectl get pvc -n affine
Initially, the PVC may show a Pending status. This is expected because the default civo-volume StorageClass uses the WaitForFirstConsumer binding mode, meaning a volume is only provisioned when a pod starts using the claim.
NAME STATUSaffine-storage Pending
Once the AFFiNE pod is deployed and mounts this PVC, Kubernetes will automatically provision the volume and bind it to the claim.
With persistent storage configured, we can now deploy Redis, which AFFiNE uses for caching and background job processing.
Deploy Redis
AFFiNE uses Redis for caching and background task processing. We'll deploy Redis inside the Kubernetes cluster and expose it internally using a ClusterIP service.
Create a file named redis.yaml:
nano redis.yaml
Add the Redis Deployment and Service manifests:
apiVersion: v1kind: PersistentVolumeClaimmetadata:name: redis-storagenamespace: affinespec:accessModes:- ReadWriteOncestorageClassName: civo-volumeresources:requests:storage: 2Gi---apiVersion: apps/v1kind: Deploymentmetadata:name: redisnamespace: affinespec:replicas: 1selector:matchLabels:app: redistemplate:metadata:labels:app: redisspec:containers:- name: redisimage: redis:7-alpinecommand: ["redis-server", "--appendonly", "yes"]ports:- containerPort: 6379volumeMounts:- name: redis-datamountPath: /datavolumes:- name: redis-datapersistentVolumeClaim:claimName: redis-storage---apiVersion: v1kind: Servicemetadata:name: redisnamespace: affinespec:selector:app: redisports:- port: 6379targetPort: 6379
Apply the manifest:
kubectl apply -f redis.yaml
Expected output:
deployment.apps/redis createdservice/redis created
Verify that the Redis pod is running:
kubectl get pods -n affine
Expected output:
NAME READY STATUSredis-596d56fb4f-456hr 1/1 Running
Next, verify that the Redis service has been created:
kubectl get svc -n affine
Expected output:
NAME TYPE CLUSTER-IP PORT(S)redis ClusterIP 10.43.xxx.xxx 6379/TCP
We'll use this hostname when configuring AFFiNE to connect to Redis in the next section.
Create Kubernetes secrets
AFFiNE requires access to both PostgreSQL and Redis. Rather than hardcoding connection details in the deployment manifest, we'll store them securely using Kubernetes Secrets.
Create a file named secret.yaml:
nano secret.yaml
Add the following configuration, replacing the database credentials with your own values:
apiVersion: v1kind: Secretmetadata:name: affine-secretnamespace: affinetype: OpaquestringData:DATABASE_URL: postgresql://civo:<PASSWORD>@<HOST>:5432/affineREDIS_SERVER_HOST: redisAFFINE_SERVER_EXTERNAL_URL: "https://affine.yourdomain.com"AFFINE_ADMIN_EMAIL: "admin@yourdomain.com"AFFINE_ADMIN_PASSWORD: "SuperSecurePassword123!"TELEMETRY_ENABLE: "false"
Replace:
- <PASSWORD> with actual password
- <HOST> with the actual host
- <affine.yourdomain.com> replace with actual configured domain.
- <admin@yourdomain.com> replace with actual configured domain email.
AFFiNE configuration variables explained
It is useful to understand the role of each variable before you apply the secret:
- AFFINE_ADMIN_EMAIL and AFFINE_ADMIN_PASSWORD: These credentials define your primary administrative user. You will need these specific login details to access the dashboard when you first launch the instance.
- TELEMETRY_ENABLE: By setting this value to "false", you disable tracking and ensure all activity remains localized to your environment.
- AFFINE_SERVER_EXTERNAL_URL: This defines the public-facing endpoint for the application, which is critical for the proper rendering of links and shared workspace resources.
Apply the manifest:
kubectl apply -f secret.yaml
Expected output:
secret/affine-secret created
Verify that the secret has been created successfully:
kubectl get secrets -n affine
Expected output:
NAME TYPE DATAaffine-secret Opaque 2
You can inspect the secret metadata using:
kubectl describe secret affine-secret -n affine
Expected output:
Name: affine-secretNamespace: affineData====DATABASE_URL: <value hidden>REDIS_SERVER_HOST: <value hidden>
At this point, Kubernetes can securely provide the PostgreSQL and Redis connection details to the AFFiNE containers without exposing sensitive credentials directly in the deployment manifests.
Configure AI features (Semantic search & copilot)
To make this workspace truly "AI-ready," we must pass a configuration file to AFFiNE containing our AI provider's API key. We will store this securely as a Kubernetes Secret.
Create a file named ai-config.yaml:
nano ai-config.yaml
apiVersion: v1kind: Secretmetadata:name: affine-confignamespace: affinetype: OpaquestringData:config.json: |{"copilot": {"enabled": true,"providers.openai": {"apiKey": "<YOUR_OPENAI_API_KEY>"}}}
Replace:
- <YOUR_OPENAI_API_KEY> with your actual API key
Apply the secret to the cluster:
kubectl apply -f ai-config.yaml
Run the database migrations
Before starting the AFFiNE application, we need to initialize the PostgreSQL database schema. AFFiNE provides a migration container that creates the required tables and prepares the database for the application.
Create a file named migration.yaml:
nano migration.yaml
Add the migration Job manifest:
apiVersion: batch/v1kind: Jobmetadata:name: affine-migrationnamespace: affinespec:template:spec:restartPolicy: Nevercontainers:- name: migrationimage: ghcr.io/toeverything/affine:stablecommand: ["sh","-c","node ./scripts/self-host-predeploy.js"]envFrom:- secretRef:name: affine-secret
Apply the manifest:
kubectl apply -f migration.yaml
Expected output:
job.batch/affine-migration created
You can monitor the migration job using:
kubectl get jobs -n affine
Once the migration completes successfully, you should see output similar to:
NAME STATUS COMPLETIONSaffine-migration Complete 1/1
To view the migration logs, run:
kubectl logs job/affine-migration -n affine
Finally, verify that the job completed successfully:
kubectl get jobs -n affine
Expected output:
NAME STATUS COMPLETIONS DURATIONaffine-migration Complete 1/1 37s
With the database schema initialized, we're ready to deploy the AFFiNE application and connect it to Redis, PostgreSQL, and persistent storage.
Troubleshooting a failed migration job
Because this job is configured with restartPolicy: Never, it will not automatically retry if something is wrong. If the status shows 0/1 completions or enters a Failed state, use these steps to fix it:
- Check the logs for errors: Run this command to see why the migration failed:
kubectl logs job/affine-migration -n affine
- Delete the failed job: Kubernetes jobs are immutable, meaning you cannot update or re-run a failed job. You must delete the old one first:
kubectl delete job affine-migration -n affine
- Fix and retry: Correct any mistakes in your
secret.yamlfile, re-apply it usingkubectl apply -f secret.yaml, and then re-run your migration manifest:
kubectl apply -f migration.yaml
- Once you add this block to your document, you can tick off this comment as Resolved. Paste the next piece of feedback whenever you are ready!
Deploy AFFiNE
With PostgreSQL, Redis, and the database schema in place, we can now deploy the AFFiNE application. The deployment will connect to the managed PostgreSQL database, use Redis for caching, and mount persistent storage for application data.
Create a file named affine-deployment.yaml:
nano affine-deployment.yaml
Add the deployment manifest:
apiVersion: apps/v1kind: Deploymentmetadata:name: affinenamespace: affinespec:replicas: 1selector:matchLabels:app: affinetemplate:metadata:labels:app: affinespec:containers:- name: affineimage: ghcr.io/toeverything/affine:stableports:- containerPort: 3010envFrom:- secretRef:name: affine-secretvolumeMounts:- name: affine-storagemountPath: /root/.affine/storage# NEW: Put the AI config file into the app- name: ai-config-volumemountPath: /root/.affine/configvolumes:- name: affine-storagepersistentVolumeClaim:claimName: affine-storage# NEW: Get the AI config from your secret- name: ai-config-volumesecret:secretName: affine-config
Apply the manifest:
kubectl apply -f affine-deployment.yaml
Expected output:
deployment.apps/affine created
Verify that the AFFiNE pod is running:
kubectl get pods -n affine
Expected output:
NAME READY STATUSaffine-xxxxxxxxxx-xxxxx 1/1 Running
You can also inspect the deployment:
kubectl get deployments -n affine
Expected output:
NAME READY UP-TO-DATE AVAILABLEaffine 1/1 1 1
At this stage, the AFFiNE application is running inside the cluster and connected to both Redis and PostgreSQL. The persistent volume claim should also become bound automatically once the pod starts using it.
Verify the storage status:
kubectl get pvc -n affine
Expected output:
NAME STATUSaffine-storage Bound
With the application successfully deployed, the final step is to expose it outside the cluster so it can be accessed from a web browser.
Open firewall ports for web traffic
Before we expose the application to the internet, we must ensure the Civo cluster firewall allows incoming web traffic. This allows users to reach your site and allows Let's Encrypt to verify your domain for a security certificate.
- Log into your Civo Dashboard and navigate to Networking > Firewalls.
- Select the firewall attached to your K3s cluster.
- Under Inbound Rules, add two new rules:
- Port:
80(Source:0.0.0.0/0) - Port:
443(Source:0.0.0.0/0)
- Port:
- Save the firewall rules.
Point your domain to the cluster
We want users to access AFFiNE using a custom domain. We need to point your domain name to the public IP address of a node in your cluster.
Run this command to find the external IP of your nodes:
kubectl get nodes -o wide
Copy the IP address listed under the EXTERNAL-IP column.
Log into your domain registrar (such as Namecheap or GoDaddy) and create an A Record. Point your custom domain (e.g., affine.yourdomain.com) to this IP address. Save the record and wait a few minutes for the internet to update.
Expose the application using Traefik
The AFFiNE application is currently running inside the Kubernetes cluster,Civo K3s comes with Traefik pre-installed to route traffic. First, we will create an internal ClusterIP service for AFFiNE.
Create a file named affine-service.yaml:
nano affine-service.yaml
Add the following Service manifest:
apiVersion: v1kind: Servicemetadata:name: affinenamespace: affinespec:type: LoadBalancerselector:app: affineports:- port: 80targetPort: 3010
Apply the manifest:
kubectl apply -f affine-service.yaml
Expected output:
service/affine created
Verify that the service has been created:
kubectl get svc -n affine
Secure the application with HTTPS
To make sure data is safe, we will use cert-manager to automatically fetch free SSL certificates from Let's Encrypt.
Install cert-manager using the official release manifest:
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.4/cert-manager.yaml
Wait a minute for the pods to start, and verify they are running:
kubectl get pods -n cert-manager
Create a file named cluster-issuer.yaml:
nano cluster-issuer.yaml
Add the following code:
apiVersion: cert-manager.io/v1kind: ClusterIssuermetadata:name: letsencrypt-prodspec:acme:server: https://acme-v02.api.letsencrypt.org/directoryemail: your-email@example.comprivateKeySecretRef:name: letsencrypt-prodsolvers:- http01:ingress:class: traefik
Replace:
- your-email@example.com with actual configured domain email.
Apply the ClusterIssuer:
kubectl apply -f cluster-issuer.yaml
Create an Ingress resource. This tells Traefik to route traffic from your custom domain to AFFiNE and secure it with the certificate.
Create a file named affine-ingress.yaml:
nano affine-ingress.yaml
Add the following code:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: affine-ingressnamespace: affineannotations:cert-manager.io/cluster-issuer: "letsencrypt-prod"ingress.kubernetes.io/ssl-redirect: "true"spec:tls:- hosts:- affine.yourdomain.comsecretName: affine-tls-secretrules:- host: affine.yourdomain.comhttp:paths:- path: /pathType: Prefixbackend:service:name: affineport:number: 80
Replace <affine.yourdomain.com> with your actual domain
Apply the manifest:
kubectl apply -f affine-ingress.yaml
Wait about one minute. You can check the status of your SSL certificate by running:
kubectl get certificate -n affine
When the READY column says True, your SSL certificate is installed.
Verify the deployment
Now that AFFiNE is accessible through the LoadBalancer service, let's verify that all components are working correctly.
First, check that all pods are running successfully:
kubectl get pods -n affine
Expected output:
NAME READY STATUSaffine-xxxxxxxxxx-xxxxx 1/1 Runningredis-xxxxxxxxxx-xxxxx 1/1 Running
Next, verify that the persistent volume claim has been bound successfully:
kubectl get pvc -n affine
Expected output:
NAME STATUSaffine-storage Bound
You can also confirm that the migration job completed successfully:
kubectl get jobs -n affine
Expected output:
NAME STATUS COMPLETIONSaffine-migration Complete 1/1
Next, open your web browser and navigate to your secure custom domain: https://affine.yourdomain.com
Replace <affine.yourdomain.com> with your actual domain

You should be presented with the AFFiNE setup screen. Complete the initial setup process and create your first workspace.
Once the workspace is created, try creating a document or whiteboard to confirm that the application is functioning correctly and that data is being stored successfully.

At this point, you have successfully deployed AFFiNE on a Civo K3s Kubernetes cluster using a managed PostgreSQL database, Redis, persistent storage, and Kubernetes YAML manifests.
Cleaning up resources
If you were following along for testing purposes and do not want to keep AFFiNE running, it is important to clean up your resources to avoid unexpected charges.
First, delete all Kubernetes resources (including the AFFiNE deployment, Redis, persistent volumes, and secrets) by deleting the entire namespace:
kubectl delete namespace affine
Next, delete the cluster and database from your Civo account:
- Log into the Civo Dashboard.
- Navigate to Kubernetes and delete your K3s cluster.
- Navigate to Databases and delete your Managed PostgreSQL instance.
Log into your domain registrar (e.g., Namecheap or GoDaddy) and remove the A Record you created so your custom domain no longer points to the deleted cluster.
Summary
In this tutorial, we deployed AFFiNE on a Civo K3s Kubernetes cluster using Kubernetes YAML manifests. We provisioned a Civo Managed PostgreSQL database, deployed Redis for caching, configured persistent storage, initialised the database schema, and exposed the application through a LoadBalancer service.
By running AFFiNE on Kubernetes, we gain improved reliability, easier scalability, and better operational flexibility compared to a single-server deployment. This architecture provides a solid foundation for teams looking to self-host an open-source workspace platform while maintaining control over their data and infrastructure.

Technical Writer
Abdul Talha is a technical writer who specializes in self-hosting and cloud deployment, writing hands-on, practical guides for developer tools. With a background in development, he's seen firsthand how things break in real environments, which is why every guide he publishes is deployed and tested end-to-end on a live server before it's written, never assumed, never just tested locally. His work often goes beyond setup, digging into why something isn't working and breaking it down into clear, actionable fixes, with a particular focus on Docker, WSL, and self-hosted systems. Working remotely and asynchronously, he manages the full documentation process independently, from testing to publication, using a Docs-as-Code workflow with Git and Markdown.
Share this article