Skip to main content

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.

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).
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.
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 instead, which covers IRSA, S3 configuration, and ALB Ingress.

Prerequisites

Architecture

Configuration

Deploy

Verify

Troubleshooting


Prerequisites

Required tools

ToolPurposeInstallation
kubectlKubernetes cluster managementDownload

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:
ComponentImagePurposePorts
Webdraftable/apish-webDjango web application, API endpoints8000
Celery Workerdraftable/apish-webBackground task processing
Celery Beatdraftable/apish-webTask scheduler
Comparedraftable/apish-compareDocument comparison engine (.NET)— (communicates via AMQP)
Converterdraftable/apish-converterOffice-to-PDF conversion (LibreOffice)8080
PostgreSQLpostgres:16-alpineDatabase5432
Redisredis:8-alpineCaching, sessions, license storage6379
RabbitMQrabbitmq:4-management-alpineMessage broker5672

Stateful vs stateless components

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.
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.
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 for details.

Configuration

Namespace

Create a dedicated namespace for Draftable resources:
# 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:
# 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"
Change all default passwords before deploying to production. The django-secret-key should be a random alphanumeric string of at least 64 characters.

ConfigMap

Create a ConfigMap with application configuration. Update the placeholder values for your environment:
# 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"
For a complete reference of all available environment variables, see the Docker Compose Guide.

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:
# 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
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.

Infrastructure services

# 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
# 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
# 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
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.
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:
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.

Database migrations

Run the database migration job before deploying application services:
# 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

# 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
# 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
# 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
Only one replica: Celery Beat must run as a single replica. Running multiple instances will cause duplicate task scheduling.
# 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: {}
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.
# 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
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.
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.

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):
# 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
To use TLS, create a Kubernetes Secret with your certificate:
kubectl create secret tls draftable-tls -n draftable \
  --cert=/path/to/tls.crt \
  --key=/path/to/tls.key

Deployment order

Apply the manifests in the following order:
1

Create namespace and configuration

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
2

Deploy infrastructure services

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

Run database migrations

kubectl apply -f 20-web-init-job.yaml
kubectl wait --for=condition=complete job/web-init -n draftable --timeout=300s
4

Deploy application services

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
5

Deploy Ingress

kubectl apply -f 30-ingress.yaml

Verification

Check pod status

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

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

# 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:
ComponentIn-Cluster (Dev/Test)Production Alternative
PostgreSQL10-postgresql.yamlManaged PostgreSQL (e.g. Amazon RDS, Google Cloud SQL, Azure Database)
Redis12-redis.yamlManaged Redis (e.g. Amazon ElastiCache, Google Memorystore, Azure Cache)
RabbitMQ11-rabbitmq.yamlManaged 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:
    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.

Support

If you encounter issues during deployment, please contact us at 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