> ## Documentation Index
> Fetch the complete documentation index at: https://help.draftable.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API Self-Hosted v3 – Kubernetes Deployment Guide

> Guide for deploying Draftable API Self-Hosted v3 on Kubernetes using local file storage.

This guide provides instructions for deploying Draftable API Self-Hosted v3 on a **generic Kubernetes cluster** using local file storage. It is platform-agnostic and applies to any Kubernetes distribution (e.g. EKS, GKE, AKS, on-premises, Rancher, OpenShift).

<Warning>
  **Generic guidance**: Due to the broad nature and diversity of Kubernetes distributions, cluster configurations, and infrastructure providers, this guide is **generic in scope**. The manifests and instructions provided are intended as a reference starting point and may require adaptation for your specific environment — including StorageClass names, Ingress controller annotations, resource limits, security policies, and networking configuration. Your infrastructure team should review and adjust the manifests to align with your organisation's Kubernetes standards and operational requirements.
</Warning>

<Note>
  **AWS EKS with S3 storage**: If you are deploying on AWS EKS and wish to use S3 for file storage, refer to the dedicated [AWS EKS Deployment Guide](/hc/en-us/articles/API-Self-Hosted-v3-AWS-EKS-Deployment-Guide) instead, which covers IRSA, S3 configuration, and ALB Ingress.
</Note>

<CardGroup>
  <Card title="Prerequisites" icon="clipboard-check" href="#prerequisites" iconType="solid" horizontal />

  <Card title="Architecture" icon="sitemap" href="#architecture-overview" iconType="solid" horizontal />

  <Card title="Configuration" icon="gear" href="#configuration" iconType="solid" horizontal />

  <Card title="Deploy" icon="rocket" href="#deploy-draftable" iconType="solid" horizontal />

  <Card title="Verify" icon="check" href="#verification" iconType="solid" horizontal />

  <Card title="Troubleshooting" icon="wrench" href="#troubleshooting" iconType="solid" horizontal />
</CardGroup>

***

## Prerequisites

### Required tools

| Tool        | Purpose                       | Installation                                        |
| ----------- | ----------------------------- | --------------------------------------------------- |
| **kubectl** | Kubernetes cluster management | [Download](https://kubernetes.io/docs/tasks/tools/) |

### Required information

* A running Kubernetes cluster with `kubectl` configured
* Draftable container images available (either pulled from the Draftable registry or pre-loaded into your cluster)
* Draftable product license key
* Domain name for the application (if exposing externally)

### Hardware requirements

Your cluster nodes should meet these minimum requirements:

* **CPU**: 4 vCPU total available for Draftable workloads
* **Memory**: 8 GB RAM total available
* **Storage**: PersistentVolume provisioner available in your cluster

***

## Architecture overview

### Component architecture

Draftable API Self-Hosted consists of several microservices:

| Component         | Image                          | Purpose                                | Ports                     |
| ----------------- | ------------------------------ | -------------------------------------- | ------------------------- |
| **Web**           | `draftable/apish-web`          | Django web application, API endpoints  | 8000                      |
| **Celery Worker** | `draftable/apish-web`          | Background task processing             | —                         |
| **Celery Beat**   | `draftable/apish-web`          | Task scheduler                         | —                         |
| **Compare**       | `draftable/apish-compare`      | Document comparison engine (.NET)      | — (communicates via AMQP) |
| **Converter**     | `draftable/apish-converter`    | Office-to-PDF conversion (LibreOffice) | 8080                      |
| **PostgreSQL**    | `postgres:16-alpine`           | Database                               | 5432                      |
| **Redis**         | `redis:8-alpine`               | Caching, sessions, license storage     | 6379                      |
| **RabbitMQ**      | `rabbitmq:4-management-alpine` | Message broker                         | 5672                      |

### Stateful vs stateless components

<Warning>
  **Production deployments**: For production, you should use managed database and caching services provided by your cloud platform (e.g. Amazon RDS, Google Cloud SQL, Azure Database for PostgreSQL) rather than running stateful workloads inside Kubernetes. Pod restarts, node failures, or cluster updates can result in data loss for in-cluster databases.
</Warning>

The application components (Web, Celery, Compare, Converter) are **stateless** and well-suited for Kubernetes. The infrastructure components (PostgreSQL, Redis, RabbitMQ) are **stateful** — the manifests below run them in-cluster for simplicity, but managed services are recommended for production.

### Shared storage requirement

When using `FILE_STORAGE_TYPE=local`, the Web, Celery Worker, Celery Beat, and Compare containers must all have access to the same shared filesystem at `/srv/draftable`. In Kubernetes, this requires a `ReadWriteMany` (RWX) PersistentVolume — for example, NFS, CephFS, or a cloud-native file share.

<Warning>
  **NFS performance**: Draftable does not test or optimise against NFS storage. While functional, NFS-based deployments may experience performance issues under comparison workloads. If your platform supports S3-compatible object storage, consider using `FILE_STORAGE_TYPE=s3` instead. See [File Storage](/hc/en-us/articles/51141426113049-Draftable-API-Self-Hosted-v3-Docker-Compose-Guide#file-storage) for details.
</Warning>

***

## Configuration

### Namespace

Create a dedicated namespace for Draftable resources:

```yaml theme={null}
# 00-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: draftable
```

### Secrets

Create a Kubernetes Secret containing sensitive configuration. Replace the placeholder values with your own:

```yaml theme={null}
# 01-secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: draftable-secrets
  namespace: draftable
type: Opaque
stringData:
  db-password: "YOUR_SECURE_DB_PASSWORD"
  amqp-password: "YOUR_SECURE_AMQP_PASSWORD"
  redis-password: "YOUR_SECURE_REDIS_PASSWORD"
  django-secret-key: "YOUR_64_CHARACTER_RANDOM_STRING"
  draftable-product-key: "YOUR-DRAFTABLE-LICENSE-KEY"
```

<Warning>
  **Change all default passwords** before deploying to production. The `django-secret-key` should be a random alphanumeric string of at least 64 characters.
</Warning>

### ConfigMap

Create a ConfigMap with application configuration. Update the placeholder values for your environment:

```yaml theme={null}
# 02-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: draftable-config
  namespace: draftable
data:
  # Application
  APP_BASE_URL: "https://draftable.yourcompany.com"
  ENV: "prod"
  ALLOWED_HOSTS: "*"
  CSRF_TRUSTED_ORIGINS: "https://draftable.yourcompany.com"
  REQUIRE_HTTPS: "true"

  # Database
  DB_HOST: "postgresql-svc"
  DB_PORT: "5432"
  DB_NAME: "draftable"
  DB_USER: "postgres"

  # Redis
  REDIS_HOST: "redis-svc"
  REDIS_PORT: "6379"

  # RabbitMQ
  AMQP_HOST: "rabbitmq-svc"
  AMQP_PORT: "5672"
  AMQP_USER: "draftable"

  # File storage
  FILE_STORAGE_TYPE: "local"

  # Compare
  COMPARE_WORKERS_COUNT: "1"
  JODCONVERTER_URL: "http://converter-svc:8080/lool/convert-to/pdf"
```

<Note>
  For a complete reference of all available environment variables, see the [Docker Compose Guide](/hc/en-us/articles/51141426113049-Draftable-API-Self-Hosted-v3-Docker-Compose-Guide).
</Note>

***

## Deploy Draftable

### Shared storage

Create a PersistentVolumeClaim for the shared application data volume. This must use a `ReadWriteMany` access mode so it can be mounted by multiple pods simultaneously:

```yaml theme={null}
# 05-shared-storage.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: draftable-data
  namespace: draftable
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 20Gi
  # storageClassName: your-rwx-storage-class
```

<Note>
  Uncomment and set `storageClassName` to match your cluster's RWX-capable StorageClass (e.g. `nfs`, `efs-sc`, `cephfs`). If your cluster has a default RWX StorageClass, you can omit this field.
</Note>

### Infrastructure services

<Accordion title="PostgreSQL">
  ```yaml theme={null}
  # 10-postgresql.yaml
  apiVersion: v1
  kind: PersistentVolumeClaim
  metadata:
    name: postgresql-data
    namespace: draftable
  spec:
    accessModes:
      - ReadWriteOnce
    resources:
      requests:
        storage: 10Gi
  ---
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: postgresql
    namespace: draftable
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: postgresql
    template:
      metadata:
        labels:
          app: postgresql
      spec:
        containers:
          - name: postgresql
            image: postgres:16-alpine
            ports:
              - containerPort: 5432
            env:
              - name: POSTGRES_DB
                value: "draftable"
              - name: POSTGRES_USER
                value: "postgres"
              - name: POSTGRES_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: db-password
            volumeMounts:
              - name: data
                mountPath: /var/lib/postgresql/data
                subPath: pgdata
            readinessProbe:
              exec:
                command: ["pg_isready", "-U", "postgres", "-d", "draftable"]
              initialDelaySeconds: 10
              periodSeconds: 5
            resources:
              requests:
                cpu: 250m
                memory: 256Mi
              limits:
                cpu: "1"
                memory: 1Gi
        volumes:
          - name: data
            persistentVolumeClaim:
              claimName: postgresql-data
  ---
  apiVersion: v1
  kind: Service
  metadata:
    name: postgresql-svc
    namespace: draftable
  spec:
    selector:
      app: postgresql
    ports:
      - port: 5432
        targetPort: 5432
  ```
</Accordion>

<Accordion title="RabbitMQ">
  ```yaml theme={null}
  # 11-rabbitmq.yaml
  apiVersion: v1
  kind: PersistentVolumeClaim
  metadata:
    name: rabbitmq-data
    namespace: draftable
  spec:
    accessModes:
      - ReadWriteOnce
    resources:
      requests:
        storage: 5Gi
  ---
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: rabbitmq
    namespace: draftable
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: rabbitmq
    template:
      metadata:
        labels:
          app: rabbitmq
      spec:
        containers:
          - name: rabbitmq
            image: rabbitmq:4-management-alpine
            ports:
              - containerPort: 5672
              - containerPort: 15672
            env:
              - name: RABBITMQ_DEFAULT_USER
                value: "draftable"
              - name: RABBITMQ_DEFAULT_PASS
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: amqp-password
            volumeMounts:
              - name: data
                mountPath: /var/lib/rabbitmq
            readinessProbe:
              exec:
                command: ["rabbitmq-diagnostics", "-q", "ping"]
              initialDelaySeconds: 30
              periodSeconds: 10
            resources:
              requests:
                cpu: 250m
                memory: 256Mi
              limits:
                cpu: "1"
                memory: 1Gi
        volumes:
          - name: data
            persistentVolumeClaim:
              claimName: rabbitmq-data
  ---
  apiVersion: v1
  kind: Service
  metadata:
    name: rabbitmq-svc
    namespace: draftable
  spec:
    selector:
      app: rabbitmq
    ports:
      - name: amqp
        port: 5672
        targetPort: 5672
      - name: management
        port: 15672
        targetPort: 15672
  ```
</Accordion>

<Accordion title="Redis">
  ```yaml theme={null}
  # 12-redis.yaml
  apiVersion: v1
  kind: PersistentVolumeClaim
  metadata:
    name: redis-data
    namespace: draftable
  spec:
    accessModes:
      - ReadWriteOnce
    resources:
      requests:
        storage: 2Gi
  ---
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: redis
    namespace: draftable
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: redis
    template:
      metadata:
        labels:
          app: redis
      spec:
        containers:
          - name: redis
            image: redis:8-alpine
            ports:
              - containerPort: 6379
            command:
              - "redis-server"
              - "--requirepass"
              - "$(REDIS_PASSWORD)"
              - "--appendonly"
              - "yes"
            env:
              - name: REDIS_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: redis-password
            volumeMounts:
              - name: data
                mountPath: /data
            readinessProbe:
              exec:
                command: ["sh", "-c", "redis-cli -a $REDIS_PASSWORD ping"]
              initialDelaySeconds: 10
              periodSeconds: 5
            resources:
              requests:
                cpu: 100m
                memory: 128Mi
              limits:
                cpu: 500m
                memory: 512Mi
        volumes:
          - name: data
            persistentVolumeClaim:
              claimName: redis-data
  ---
  apiVersion: v1
  kind: Service
  metadata:
    name: redis-svc
    namespace: draftable
  spec:
    selector:
      app: redis
    ports:
      - port: 6379
        targetPort: 6379
  ```

  <Warning>
    The Redis service is intentionally named `redis-svc` rather than `redis`. Kubernetes automatically creates an environment variable `REDIS_PORT=tcp://ip:port` for any service named `redis`, which conflicts with the integer port value expected by the application.
  </Warning>

  <Warning>
    **Redis persistence is critical.** Redis stores the activated license data and any custom fonts uploaded to the application. If Redis data is lost (e.g. pod restart without persistent storage), all users will be locked out until the license is reactivated. Always use a PersistentVolumeClaim for Redis data, and for production, use a managed Redis service with persistence enabled.

    If Redis data is lost, re-run the `web-init` job to reactivate the license from the `DRAFTABLE_PRODUCT_KEY` environment variable:

    ```bash theme={null}
    kubectl delete job web-init -n draftable --ignore-not-found
    kubectl apply -f 20-web-init-job.yaml
    kubectl wait --for=condition=complete job/web-init -n draftable --timeout=300s
    ```

    You do **not** need to restart the entire deployment — only the `web-init` job needs to run.
  </Warning>
</Accordion>

### Database migrations

Run the database migration job before deploying application services:

```yaml theme={null}
# 20-web-init-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: web-init
  namespace: draftable
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: web-init
          image: draftable/apish-web:latest
          env:
            - name: APP_MODE
              value: "web_init"
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: draftable-secrets
                  key: db-password
            - name: AMQP_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: draftable-secrets
                  key: amqp-password
            - name: REDIS_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: draftable-secrets
                  key: redis-password
            - name: DJANGO_SECRET_KEY
              valueFrom:
                secretKeyRef:
                  name: draftable-secrets
                  key: django-secret-key
            - name: DRAFTABLE_PRODUCT_KEY
              valueFrom:
                secretKeyRef:
                  name: draftable-secrets
                  key: draftable-product-key
          envFrom:
            - configMapRef:
                name: draftable-config
          volumeMounts:
            - name: app-data
              mountPath: /srv/draftable
      volumes:
        - name: app-data
          persistentVolumeClaim:
            claimName: draftable-data
```

### Application services

<Accordion title="Web">
  ```yaml theme={null}
  # 21-web.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: web
    namespace: draftable
  spec:
    replicas: 2
    selector:
      matchLabels:
        app: web
    template:
      metadata:
        labels:
          app: web
      spec:
        containers:
          - name: web
            image: draftable/apish-web:latest
            ports:
              - containerPort: 8000
            env:
              - name: APP_MODE
                value: "web"
              - name: DB_PASS
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: db-password
              - name: AMQP_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: amqp-password
              - name: REDIS_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: redis-password
              - name: DJANGO_SECRET_KEY
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: django-secret-key
              - name: DRAFTABLE_PRODUCT_KEY
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: draftable-product-key
            envFrom:
              - configMapRef:
                  name: draftable-config
            volumeMounts:
              - name: app-data
                mountPath: /srv/draftable
            readinessProbe:
              httpGet:
                path: /api/?healthcheck
                port: 8000
              initialDelaySeconds: 30
              periodSeconds: 10
            livenessProbe:
              httpGet:
                path: /api/?healthcheck
                port: 8000
              initialDelaySeconds: 60
              periodSeconds: 30
            resources:
              requests:
                cpu: 250m
                memory: 512Mi
              limits:
                cpu: "1"
                memory: 2Gi
        volumes:
          - name: app-data
            persistentVolumeClaim:
              claimName: draftable-data
  ---
  apiVersion: v1
  kind: Service
  metadata:
    name: web-svc
    namespace: draftable
  spec:
    selector:
      app: web
    ports:
      - port: 8000
        targetPort: 8000
  ```
</Accordion>

<Accordion title="Celery Worker">
  ```yaml theme={null}
  # 22-celery-worker.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: celery-worker
    namespace: draftable
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: celery-worker
    template:
      metadata:
        labels:
          app: celery-worker
      spec:
        containers:
          - name: celery-worker
            image: draftable/apish-web:latest
            env:
              - name: APP_MODE
                value: "celery_worker"
              - name: DB_PASS
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: db-password
              - name: AMQP_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: amqp-password
              - name: REDIS_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: redis-password
              - name: DJANGO_SECRET_KEY
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: django-secret-key
              - name: DRAFTABLE_PRODUCT_KEY
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: draftable-product-key
            envFrom:
              - configMapRef:
                  name: draftable-config
            volumeMounts:
              - name: app-data
                mountPath: /srv/draftable
            resources:
              requests:
                cpu: 250m
                memory: 512Mi
              limits:
                cpu: "1"
                memory: 2Gi
        volumes:
          - name: app-data
            persistentVolumeClaim:
              claimName: draftable-data
  ```
</Accordion>

<Accordion title="Celery Beat">
  ```yaml theme={null}
  # 23-celery-beat.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: celery-beat
    namespace: draftable
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: celery-beat
    template:
      metadata:
        labels:
          app: celery-beat
      spec:
        containers:
          - name: celery-beat
            image: draftable/apish-web:latest
            env:
              - name: APP_MODE
                value: "celery_beat"
              - name: DB_PASS
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: db-password
              - name: AMQP_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: amqp-password
              - name: REDIS_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: redis-password
              - name: DJANGO_SECRET_KEY
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: django-secret-key
              - name: DRAFTABLE_PRODUCT_KEY
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: draftable-product-key
            envFrom:
              - configMapRef:
                  name: draftable-config
            volumeMounts:
              - name: app-data
                mountPath: /srv/draftable
            resources:
              requests:
                cpu: 100m
                memory: 256Mi
              limits:
                cpu: 500m
                memory: 512Mi
        volumes:
          - name: app-data
            persistentVolumeClaim:
              claimName: draftable-data
  ```

  <Warning>
    **Only one replica**: Celery Beat must run as a single replica. Running multiple instances will cause duplicate task scheduling.
  </Warning>
</Accordion>

<Accordion title="Compare">
  ```yaml theme={null}
  # 24-compare.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: compare
    namespace: draftable
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: compare
    template:
      metadata:
        labels:
          app: compare
      spec:
        containers:
          - name: compare
            image: draftable/apish-compare:latest
            env:
              - name: COMPARE_WORKERS_COUNT
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: COMPARE_WORKERS_COUNT
              - name: APP_BASE_URL
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: APP_BASE_URL
              - name: AMQP_HOST
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: AMQP_HOST
              - name: AMQP_PORT
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: AMQP_PORT
              - name: AMQP_USER
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: AMQP_USER
              - name: AMQP_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: amqp-password
              - name: REDIS_HOST
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: REDIS_HOST
              - name: REDIS_PORT
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: REDIS_PORT
              - name: REDIS_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: redis-password
              - name: JODCONVERTER_URL
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: JODCONVERTER_URL
            volumeMounts:
              - name: app-data
                mountPath: /srv/draftable
              - name: tmp
                mountPath: /tmp/Draftable
            livenessProbe:
              exec:
                command: ["sh", "-c", "ps aux | grep '[C]ompareService' || exit 1"]
              initialDelaySeconds: 30
              periodSeconds: 15
            resources:
              requests:
                cpu: "1"
                memory: 2Gi
              limits:
                cpu: "2"
                memory: 4Gi
        volumes:
          - name: app-data
            persistentVolumeClaim:
              claimName: draftable-data
          - name: tmp
            emptyDir: {}
  ```

  <Note>
    The Compare service is a .NET application with different environment variable requirements than the Python-based web containers. It connects directly to RabbitMQ and Redis but does not connect to PostgreSQL.
  </Note>
</Accordion>

<Accordion title="Converter">
  ```yaml theme={null}
  # 25-converter.yaml
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: converter
    namespace: draftable
  spec:
    replicas: 1
    selector:
      matchLabels:
        app: converter
    template:
      metadata:
        labels:
          app: converter
      spec:
        containers:
          - name: converter
            image: draftable/apish-converter:latest
            ports:
              - containerPort: 8080
            env:
              - name: REDIS_HOST
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: REDIS_HOST
              - name: REDIS_PORT
                valueFrom:
                  configMapKeyRef:
                    name: draftable-config
                    key: REDIS_PORT
              - name: REDIS_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: draftable-secrets
                    key: redis-password
            livenessProbe:
              tcpSocket:
                port: 8080
              initialDelaySeconds: 120
              periodSeconds: 30
              timeoutSeconds: 10
              failureThreshold: 3
            readinessProbe:
              tcpSocket:
                port: 8080
              initialDelaySeconds: 90
              periodSeconds: 10
              timeoutSeconds: 5
              failureThreshold: 6
            resources:
              requests:
                cpu: 500m
                memory: 1Gi
              limits:
                cpu: "2"
                memory: 4Gi
  ---
  apiVersion: v1
  kind: Service
  metadata:
    name: converter-svc
    namespace: draftable
  spec:
    selector:
      app: converter
    ports:
      - port: 8080
        targetPort: 8080
  ```

  <Warning>
    **TCP probes only**: The converter **must** use TCP socket probes, not HTTP probes. The JODConverter REST API returns 404 on all health endpoints (`/`, `/health`, `/actuator/health`), causing HTTP probes to fail and triggering endless restart loops. TCP probes verify port 8080 is listening, which succeeds once Tomcat starts — even while LibreOffice is still initialising.
  </Warning>

  <Note>
    During normal startup, you may see "Office process died with exit code 81" messages in the converter logs. This is expected behaviour while LibreOffice initialises and typically resolves within 90–120 seconds.
  </Note>
</Accordion>

### Ingress

Configure an Ingress to expose the web service externally. The example below uses a generic Ingress resource — adapt the annotations and TLS configuration for your Ingress controller (e.g. nginx-ingress, Traefik, HAProxy):

```yaml theme={null}
# 30-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: draftable-ingress
  namespace: draftable
  annotations:
    # Adapt these annotations for your Ingress controller
    # nginx.ingress.kubernetes.io/ssl-redirect: "true"
    # nginx.ingress.kubernetes.io/proxy-body-size: "100m"
spec:
  # ingressClassName: nginx
  tls:
    - hosts:
        - draftable.yourcompany.com
      secretName: draftable-tls
  rules:
    - host: draftable.yourcompany.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-svc
                port:
                  number: 8000
```

<Note>
  To use TLS, create a Kubernetes Secret with your certificate:

  ```bash theme={null}
  kubectl create secret tls draftable-tls -n draftable \
    --cert=/path/to/tls.crt \
    --key=/path/to/tls.key
  ```
</Note>

***

## Deployment order

Apply the manifests in the following order:

<Steps>
  <Step title="Create namespace and configuration">
    ```bash theme={null}
    kubectl apply -f 00-namespace.yaml
    kubectl apply -f 01-secrets.yaml
    kubectl apply -f 02-configmap.yaml
    kubectl apply -f 05-shared-storage.yaml
    ```
  </Step>

  <Step title="Deploy infrastructure services">
    ```bash theme={null}
    kubectl apply -f 10-postgresql.yaml
    kubectl apply -f 11-rabbitmq.yaml
    kubectl apply -f 12-redis.yaml
    ```

    Wait for all infrastructure pods to become ready:

    ```bash theme={null}
    kubectl wait --for=condition=ready pod -l app=postgresql -n draftable --timeout=300s
    kubectl wait --for=condition=ready pod -l app=rabbitmq -n draftable --timeout=300s
    kubectl wait --for=condition=ready pod -l app=redis -n draftable --timeout=300s
    ```
  </Step>

  <Step title="Run database migrations">
    ```bash theme={null}
    kubectl apply -f 20-web-init-job.yaml
    kubectl wait --for=condition=complete job/web-init -n draftable --timeout=300s
    ```
  </Step>

  <Step title="Deploy application services">
    ```bash theme={null}
    kubectl apply -f 21-web.yaml
    kubectl apply -f 22-celery-worker.yaml
    kubectl apply -f 23-celery-beat.yaml
    kubectl apply -f 24-compare.yaml
    kubectl apply -f 25-converter.yaml
    ```
  </Step>

  <Step title="Deploy Ingress">
    ```bash theme={null}
    kubectl apply -f 30-ingress.yaml
    ```
  </Step>
</Steps>

***

## Verification

### Check pod status

```bash theme={null}
kubectl get pods -n draftable
```

All pods should show `Running` status with ready containers:

```
NAME                             READY   STATUS    RESTARTS   AGE
celery-beat-xxx                  1/1     Running   0          5m
celery-worker-xxx                1/1     Running   0          5m
compare-xxx                      1/1     Running   0          5m
converter-xxx                    1/1     Running   0          5m
postgresql-xxx                   1/1     Running   0          10m
rabbitmq-xxx                     1/1     Running   0          10m
redis-xxx                        1/1     Running   0          10m
web-xxx                          1/1     Running   0          5m
web-xxx                          1/1     Running   0          5m
```

### Check services

```bash theme={null}
kubectl get svc -n draftable
```

### Access the application

Once the Ingress is configured and DNS is pointing to your cluster, open `https://draftable.yourcompany.com` in your browser.

For testing without Ingress, use port-forwarding:

```bash theme={null}
kubectl port-forward svc/web-svc 8000:8000 -n draftable
```

Then visit `http://localhost:8000`.

***

## Troubleshooting

### Pods stuck in `Pending`

**Cause**: PersistentVolumeClaims not binding.

**Solution**: Verify your cluster has a StorageClass that supports the required access modes:

```bash theme={null}
kubectl get storageclass
kubectl get pvc -n draftable
```

For the shared `draftable-data` PVC, ensure your StorageClass supports `ReadWriteMany`.

***

### `CreateContainerConfigError`

**Cause**: ConfigMap or Secret key not found.

**Solution**: Verify all ConfigMap and Secret keys exist:

```bash theme={null}
kubectl get configmap draftable-config -n draftable -o yaml
kubectl get secret draftable-secrets -n draftable -o yaml
```

***

### Users locked out after Redis restart

**Cause**: Redis stores the activated license. If Redis data is lost (pod restart without persistent storage, PVC deletion, or cache flush), the license is no longer available and all users are locked out.

**Solution**: Re-run the `web-init` job to reactivate the license from the `DRAFTABLE_PRODUCT_KEY` environment variable:

```bash theme={null}
kubectl delete job web-init -n draftable --ignore-not-found
kubectl apply -f 20-web-init-job.yaml
kubectl wait --for=condition=complete job/web-init -n draftable --timeout=300s
```

To prevent this in future, ensure Redis is using persistent storage (PVC or managed service) with persistence enabled (AOF or RDB snapshots).

***

### `REDIS_PORT` parse error (`invalid literal for int()`)

**Cause**: Kubernetes auto-creates `REDIS_PORT=tcp://ip:port` when a service is named `redis`.

**Solution**: The Redis service in these manifests is named `redis-svc` to avoid this conflict. Ensure you are using `redis-svc` as the service name.

***

### Converter in `CrashLoopBackOff`

**Cause**: HTTP health probes fail because JODConverter returns 404 on all health endpoints, and LibreOffice takes 60–90 seconds to initialise.

**Solution**: Ensure the converter uses **TCP socket probes**, not HTTP probes. See the [Converter](#converter) manifest above.

***

### `DisallowedHost` error in web logs

**Cause**: Requests arriving with a hostname not in `ALLOWED_HOSTS`.

**Solution**: Set `ALLOWED_HOSTS: "*"` in the ConfigMap, or add all hostnames that will be used to access the application (including any health check hostnames used by your Ingress controller or load balancer).

***

### Useful debugging commands

```bash theme={null}
# View all pods
kubectl get pods -n draftable

# View pod logs
kubectl logs -n draftable deployment/web --tail=100

# Describe pod for events
kubectl describe pod -n draftable -l app=web

# Check environment variables in a pod
kubectl exec -n draftable deployment/web -- env | sort

# Restart a deployment
kubectl rollout restart deployment/web -n draftable
```

***

## Using managed services

For production deployments, replace in-cluster stateful services with managed services provided by your platform:

| Component  | In-Cluster (Dev/Test) | Production Alternative                                                   |
| ---------- | --------------------- | ------------------------------------------------------------------------ |
| PostgreSQL | `10-postgresql.yaml`  | Managed PostgreSQL (e.g. Amazon RDS, Google Cloud SQL, Azure Database)   |
| Redis      | `12-redis.yaml`       | Managed Redis (e.g. Amazon ElastiCache, Google Memorystore, Azure Cache) |
| RabbitMQ   | `11-rabbitmq.yaml`    | Managed RabbitMQ (e.g. Amazon MQ, CloudAMQP)                             |

When using managed services:

1. Skip applying the corresponding in-cluster manifest (e.g. don't apply `10-postgresql.yaml`)
2. Update the ConfigMap with the managed service connection details:
   ```yaml theme={null}
   DB_HOST: "your-managed-postgres-hostname"
   DB_PORT: "5432"
   DB_TLS_MODE: "require"
   ```
3. If TLS is required, set the corresponding TLS variable (e.g. `AMQP_TLS: "true"`, `REDIS_TLS: "true"`)

***

## Configuration reference

For a complete list of all environment variables including security, CORS, HSTS, logging, and S3 storage options, see the [Docker Compose Guide – Environment Variables](/hc/en-us/articles/51141426113049-Draftable-API-Self-Hosted-v3-Docker-Compose-Guide).

## Support

If you encounter issues during deployment, please contact us at [support@draftable.com](mailto:support@draftable.com) with:

* Pod logs (`kubectl logs -n draftable deployment/<pod-name>`)
* Pod events (`kubectl describe pod -n draftable -l app=<app-name>`)
* ConfigMap values (sanitised)
* Error messages from the browser console
