Skip to main content
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

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:

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. Web, Celery Worker, Compare and Converter can all run multiple replicas: Web is stateless with database-backed sessions, and the other three are competing consumers on shared, durable queues. Celery Beat is the one exception and must run as a single replica, for the reason given with its manifest below.

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:

Secrets

Create a Kubernetes Secret containing sensitive configuration. Replace the placeholder values with your own:
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:
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:
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

Use RabbitMQ 3.13.x. Do not use RabbitMQ 4.x. RabbitMQ 4.0 removed transient non-exclusive queues, which Celery declares for its control and event queues on startup. On a 4.x broker those declarations are rejected, the connection drops, and the Celery worker never reaches a healthy state. The symptom is a Celery worker in an endless connection retry loop while every other pod looks fine. See Celery worker in a connection retry loop in Troubleshooting.This applies to the floating 4-management-alpine tag as well as specific 4.x tags, so pin an explicit 3.13.x image rather than a tag that can move onto the 4.x line.
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:
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:

Application services

Only one replica: Celery Beat must run as a single replica. Beat holds its own schedule and there is no leader election between instances, so a second Beat pod fires every scheduled job a second time. One of those jobs moves daily usage tracking data out of Redis into the database and then clears the Redis keys, so two copies running at once can race each other and produce duplicated or dropped usage records.Keep replicas: 1 and set strategy: Recreate rather than the default rolling update, so an upgrade never briefly runs two Beat pods at once.Beat is a scheduler, not part of the comparison request path. If its pod or node is lost, Kubernetes reschedules it and comparisons continue running normally in the meantime.
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.
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):
To use TLS, create a Kubernetes Secret with your certificate:

Deployment order

Apply the manifests in the following order:
1

Create namespace and configuration

2

Deploy infrastructure services

Wait for all infrastructure pods to become ready:
3

Run database migrations

4

Deploy application services

5

Deploy Ingress


Verification

Check pod status

All pods should show Running status with ready containers:

Check services

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

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

Celery worker in a connection retry loop

Cause: The broker is running RabbitMQ 4.x. RabbitMQ 4.0 removed support for transient non-exclusive queues, and Celery declares exactly that kind of queue for its control and event queues when a worker starts. The broker rejects the declaration, the connection is closed, and the worker retries indefinitely. The web, Compare and Converter pods are unaffected and look healthy, which makes this easy to misread as a networking or credentials problem. Look for repeated connection attempts in the worker log and a transient_nonexcl_queues or deprecated feature message in the broker log:
Solution: Run RabbitMQ 3.13.x. Update the image in 11-rabbitmq.yaml to rabbitmq:3.13.3-management and restart the broker and the Celery pods:
If you must stay on RabbitMQ 4.x, you can re-enable the removed behaviour in the broker’s rabbitmq.conf, though this relies on a deprecated feature and is not a long-term position:

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

HTTP 500 with FATAL: sorry, too many clients already

Cause: DB_CONN_MAX_AGE is not set to 0. With any other value, including the default, the web pods leave idle database connections open after requests until PostgreSQL reaches max_connections. Solution: Set DB_CONN_MAX_AGE: "0" in the ConfigMap, then restart the web, Celery worker and Celery Beat deployments (for example kubectl rollout restart deployment/web -n draftable). Restarting also closes the connections that have already built up. See DB_CONN_MAX_AGE in the Docker Compose Guide.

Useful debugging commands


Using managed services

For production deployments, replace in-cluster stateful services with managed services provided by your platform:
The broker must speak AMQP 0-9-1. Draftable uses AMQP 0-9-1 along with RabbitMQ features such as topic exchanges and durable queues. Despite the shared “AMQP” name, AMQP 1.0 is a different, non-interoperable protocol, so Amazon MQ for ActiveMQ will not work. If you use Amazon MQ, choose the RabbitMQ engine. Amazon SQS is also not a supported broker.Where the managed service lets you choose a version, choose 3.13.x for the reason given in the RabbitMQ manifest above.
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:
  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