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.

12 minutes reading time

Written by

Abdul Talha
Abdul Talha

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:

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>:6443
CoreDNS 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 VERSION
k3s-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: v1
kind: Namespace
metadata:
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              STATUS
affine            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: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-storage
namespace: affine
spec:
accessModes:
- ReadWriteOnce
storageClassName: civo-volume
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: affine
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: pgvector/pgvector:pg16
env:
- name: POSTGRES_USER
value: "civo"
- name: POSTGRES_PASSWORD
value: "<YOUR_POSTGRES_PASSWORD>"
- name: POSTGRES_DB
value: "affine"
- name: PGDATA
value: "/var/lib/postgresql/data/pgdata"
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-storage
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: affine
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 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    RECLAIMPOLICY
civo-volume (default)   csi.civo.com   Delete

Next, create a file named pvc.yaml:

nano pvc.yaml

Add the following configuration:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: affine-storage
namespace: affine
spec:
accessModes:
- ReadWriteOnce
storageClassName: civo-volume
resources:
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             STATUS
affine-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: v1
kind: PersistentVolumeClaim
metadata:
name: redis-storage
namespace: affine
spec:
accessModes:
- ReadWriteOnce
storageClassName: civo-volume
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: affine
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-storage
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: affine
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379

Apply the manifest:

kubectl apply -f redis.yaml

Expected output:

deployment.apps/redis created
service/redis created

Verify that the Redis pod is running:

kubectl get pods -n affine

Expected output:

NAME                     READY   STATUS
redis-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: v1
kind: Secret
metadata:
name: affine-secret
namespace: affine
type: Opaque
stringData:
DATABASE_URL: postgresql://civo:<PASSWORD>@<HOST>:5432/affine
REDIS_SERVER_HOST: redis
AFFINE_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     DATA
affine-secret   Opaque   2

You can inspect the secret metadata using:

kubectl describe secret affine-secret -n affine

Expected output:

Name:         affine-secret
Namespace:    affine
Data
====
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: v1
kind: Secret
metadata:
name: affine-config
namespace: affine
type: Opaque
stringData:
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/v1
kind: Job
metadata:
name: affine-migration
namespace: affine
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: ghcr.io/toeverything/affine:stable
command: ["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     COMPLETIONS
affine-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   DURATION
affine-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: 

  1. Check the logs for errors: Run this command to see why the migration failed: 
kubectl logs job/affine-migration -n affine
  1. 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
  1. Fix and retry: Correct any mistakes in your secret.yaml file, re-apply it using kubectl apply -f secret.yaml, and then re-run your migration manifest: 
kubectl apply -f migration.yaml
  1. 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/v1
kind: Deployment
metadata:
name: affine
namespace: affine
spec:
replicas: 1
selector:
matchLabels:
app: affine
template:
metadata:
labels:
app: affine
spec:
containers:
- name: affine
image: ghcr.io/toeverything/affine:stable
ports:
- containerPort: 3010
envFrom:
- secretRef:
name: affine-secret
volumeMounts:
- name: affine-storage
mountPath: /root/.affine/storage
# NEW: Put the AI config file into the app
- name: ai-config-volume
mountPath: /root/.affine/config
volumes:
- name: affine-storage
persistentVolumeClaim:
claimName: affine-storage
# NEW: Get the AI config from your secret
- name: ai-config-volume
secret:
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   STATUS
affine-xxxxxxxxxx-xxxxx    1/1     Running

You can also inspect the deployment:

kubectl get deployments -n affine

Expected output:

NAME     READY   UP-TO-DATE   AVAILABLE
affine   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             STATUS
affine-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.

  1. Log into your Civo Dashboard and navigate to Networking > Firewalls.
  2. Select the firewall attached to your K3s cluster.
  3. Under Inbound Rules, add two new rules:
    • Port: 80 (Source: 0.0.0.0/0)
    • Port: 443 (Source: 0.0.0.0/0)
  4. 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: v1
kind: Service
metadata:
name: affine
namespace: affine
spec:
type: LoadBalancer
selector:
app: affine
ports:
- port: 80
targetPort: 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/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your-email@example.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: traefik

Replace:

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/v1
kind: Ingress
metadata:
name: affine-ingress
namespace: affine
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
ingress.kubernetes.io/ssl-redirect: "true"
spec:
tls:
- hosts:
- affine.yourdomain.com
secretName: affine-tls-secret
rules:
- host: affine.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: affine
port:
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   STATUS
affine-xxxxxxxxxx-xxxxx     1/1     Running
redis-xxxxxxxxxx-xxxxx      1/1     Running

Next, verify that the persistent volume claim has been bound successfully:

kubectl get pvc -n affine

Expected output:

NAME             STATUS
affine-storage   Bound

You can also confirm that the migration job completed successfully:

kubectl get jobs -n affine

Expected output:

NAME               STATUS     COMPLETIONS
affine-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

AFFiNE setup screen

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.

successfully deployed AFFiNE on Civo

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:

  1. Log into the Civo Dashboard.
  2. Navigate to Kubernetes and delete your K3s cluster.
  3. 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.

Abdul Talha
Abdul Talha

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.

View author profile