> ## 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 – Azure AKS Deployment Guide

> Draftable does not recommend deploying API Self-Hosted v3 on Azure AKS. It requires unsupported network file share storage. This guide is for customers with no alternative, and documents the trade-offs and support boundaries.

<Warning>
  **Draftable does not recommend this deployment. We recommend you do not use it.**

  This is a "how to make it work" guide, not a "this works well" guide.

  Draftable API Self-Hosted v3 **can** be deployed on Azure Kubernetes Service, and we have verified
  that it works end to end. But it can only do so on **network file share storage, which is not a
  supported configuration on any platform.** We do not test against it, we do not optimise for it, and
  we cannot support problems arising from it.

  **Please choose a different platform if you possibly can.** See
  [Choosing a different platform](#choosing-a-different-platform) — on Azure, a Linux virtual machine
  running Docker Compose avoids every limitation described here, and on Kubernetes, AWS EKS with S3 is
  fully supported.

  This guide exists so that customers with a genuine constraint — a Microsoft-only mandate combined with
  a Kubernetes mandate — are not left guessing. It is not an endorsement, and following it successfully
  does not make this a supported configuration.

  Improving Azure storage support is under active consideration. Until something changes, the
  constraints described here are inherent to this platform combination and cannot be configured away.
</Warning>

## Why we do not recommend this platform

Two limitations sit behind that warning. Neither is a bug, and neither can be worked around with
configuration.

### Draftable has no Azure Blob Storage backend

API Self-Hosted supports exactly two file storage backends: **local filesystem** and **Amazon S3**.
Azure Blob Storage is not supported, and there is no way to point Draftable's S3 client at a
non-AWS, S3-compatible endpoint.

The practical consequence is that on AKS you must use `FILE_STORAGE_TYPE=local` backed by a **shared
network file share**, because the web, Celery worker, Celery beat and compare services all read and
write the same files at the same time, from potentially different nodes.

Object storage is what this product is designed around on Kubernetes. A network file share works,
but it is emulating a local disk over a network, and that carries a measurable cost described in
[Storage performance](#storage-performance).

### Azure has no managed RabbitMQ

Draftable requires **AMQP 0-9-1**. Azure Service Bus implements AMQP 1.0 and is therefore not
compatible, and Azure offers no managed RabbitMQ service of any kind.

On AWS you can use Amazon MQ for RabbitMQ and keep your message broker outside the cluster. On
Azure you must run RabbitMQ yourself, either inside the cluster or on a dedicated virtual machine.
That is a genuine reduction in resilience compared with our AWS reference architecture, and it means
you own the broker's availability and backups.

<Note>
  PostgreSQL and Redis have good managed equivalents on Azure (Azure Database for PostgreSQL Flexible
  Server and Azure Cache for Redis) and you should use them. RabbitMQ is the exception.
</Note>

## Choosing a different platform

In rough order of how well we support them:

<CardGroup cols={2}>
  <Card title="Docker Compose on a Linux VM" icon="docker" horizontal>
    **Best supported option on Azure.** Run an Azure Linux virtual machine with Docker Compose. Local
    storage is genuinely local, so none of the file share overhead applies.
  </Card>

  <Card title="AWS EKS with S3" icon="aws" href="/hc/en-us/articles/API-Self-Hosted-v3-AWS-EKS-Deployment-Guide" horizontal>
    **The fully supported Kubernetes option.** If your organisation can use AWS, this is the
    architecture the product is designed for, and the only Kubernetes storage configuration we support.
  </Card>

  <Card title="Generic Kubernetes" icon="dharmachakra" href="/hc/en-us/articles/API-Self-Hosted-v3-Kubernetes-Deployment-Guide" horizontal>
    Platform-agnostic manifests and configuration reference. **This article builds on it** — start
    there for the base manifests.
  </Card>

  <Card title="Azure AKS" icon="microsoft" horizontal>
    **Not recommended.** This article. Functional and verified, but on unsupported storage. Choose it
    only if the three options above are genuinely unavailable to you.
  </Card>
</CardGroup>

If you are on AKS purely for compute standardisation and are not bound by data residency
requirements, there is one further option worth knowing about: **AKS with an Amazon S3 bucket**. This
uses the fully supported S3 code path, and nothing prevents pods in Azure from reaching S3. It means
your documents are stored in AWS, which is unacceptable for many organisations, but if it is
acceptable for yours it avoids every limitation in this guide.

## What we tested, and what we did not

We would rather you knew the limits of our testing than assumed more than we can support.

**Verified working:**

* AKS 1.35, two `Standard_D2s_v5` nodes, `australiaeast`
* Azure Files **Premium** with the **NFS v4.1** protocol as the shared `ReadWriteMany` volume
* API Self-Hosted v3 using the `latest` image tags as of 3 August 2026 (`apish-web` 3.0.8 was current
  at that date), with in-cluster PostgreSQL 16, RabbitMQ 3.13.3 and Redis 8
* Comparisons of Word and PDF documents completing end to end via the API
* The comparison results viewer rendering correctly in a browser
* Public HTTPS via ingress-nginx and a Let's Encrypt certificate

**Not tested, and therefore not characterised:**

* **Sustained load over hours or days.** Our testing covered a single session.
* **Azure NetApp Files**, which may perform better than Azure Files and which some customers prefer
  for exactly this kind of workload.
* **Azure Files Premium with SMB** for full comparison workloads. We measured its raw file
  operations, which were worse than NFS, and did not proceed further.
* **Multi-region, private-endpoint-only, or restricted-egress network topologies.**
* **Horizontal scaling beyond a single replica of each service.**

<Warning>
  **Network file share storage is not a supported configuration.** Draftable does not test or optimise
  against NFS or SMB, and **we cannot provide support for issues arising from network file share
  storage** — including performance, file locking, and mount behaviour. This is a standing position, not
  specific to Azure.

  Draftable Support can help you with **Draftable's own configuration** on AKS: environment variables,
  licensing, container settings, and the Azure-specific issues documented in this article. We cannot
  troubleshoot Azure Files performance or your cluster's networking and storage configuration.

  If a problem is traced to file share performance or behaviour, the resolutions available to you are to
  change storage tier or to change platform. We will tell you that plainly rather than iterate on it.

  If you need a **fully supported** Kubernetes deployment, that is
  [AWS EKS with S3 storage](/hc/en-us/articles/API-Self-Hosted-v3-AWS-EKS-Deployment-Guide).
</Warning>

## Storage performance

This is the part we most want you to understand before committing.

We measured raw file operations on a live AKS cluster, comparing the node's local disk against both
Azure Files Premium protocols.

| Operation (1,000 small files) | Local disk | Azure Files Premium NFS | Azure Files Premium SMB |
| ----------------------------- | ---------: | ----------------------: | ----------------------: |
| Create                        |      30 ms |           **10,294 ms** |           **16,714 ms** |
| List directory                |       6 ms |                   27 ms |                   22 ms |
| Read                          |     608 ms |                5,904 ms |                5,076 ms |
| Delete                        |      11 ms |            **2,940 ms** |            **6,009 ms** |

**Bulk throughput is not the problem.** Sequential write bandwidth on Azure Files Premium measured
around 226 MiB/s, comfortably faster than the node's own OS disk. If you benchmark this deployment
with large files, you will conclude that storage is fast, and you will be measuring the wrong thing.

**Per-file overhead is the problem.** Creating a single file costs roughly **10 ms on NFS and 17 ms
on SMB**, against microseconds on a local disk. Draftable's workload is full of small file
operations: a `.docx` is a ZIP archive of many small parts, and comparing two documents means
unpacking all of those parts, writing page renderings, scroll maps and change data, then cleaning up.

### What that means in practice

The cost scales with the **number of files a comparison touches**, not with document size. That is why
the deployment is viable at all, rather than a reason to choose it:

| Document                                               | Internal parts | Comparison time observed    |
| ------------------------------------------------------ | -------------: | --------------------------- |
| Small Word document                                    |             17 | \~1.4 seconds               |
| 5.3 MB Word document, 250 sections, 50 embedded images |             67 | \~5.8 seconds, consistently |

Both part counts are measured, not estimated. The 5.8 second figure held to within 0.2 seconds across
every run of our final clean test pass.

<Note>
  Comparison times above were measured on a deliberately small two-node test cluster (4 vCPU total)
  and are **not** a performance prediction for your environment. They are included only to show that
  ordinary documents remain workable. Your times will differ.
</Note>

So:

* **Ordinary business documents** — tens of internal parts — remain workable. Every comparison still
  pays overhead that a supported deployment would not, but it is unlikely to be the thing you notice.
* **Documents with very large numbers of embedded objects** — hundreds or thousands of images or
  attachments — will feel it, and the cost grows linearly with part count.
* **High comparison concurrency** multiplies file operations against a single shared file share,
  which becomes the bottleneck before CPU does.

<Warning>
  **None of this overhead exists in a supported deployment.** On a Linux VM with Docker Compose, storage
  is genuinely local. On AWS EKS with S3, the product uses object storage as designed and hands large
  files to browsers directly rather than pulling them through a file share.

  Every millisecond described in this section is a cost you are choosing to take on, and it buys you
  nothing except the ability to run on AKS. If that constraint is real, proceed. If it is a preference,
  we would strongly encourage you to reconsider.
</Warning>

### Always choose NFS, never SMB

**SMB is the default protocol for Azure Files. Do not use it.** Our measurements show SMB is around
1.6 times slower than NFS at creating files and roughly twice as slow at deleting them, and Draftable
is a Linux application that expects POSIX filesystem behaviour, which NFS provides more faithfully.

NFS requires the **Premium** tier. Standard Azure Files does not offer NFS.

## Prerequisites

Start from the [generic Kubernetes Deployment Guide](/hc/en-us/articles/API-Self-Hosted-v3-Kubernetes-Deployment-Guide)
for base manifests, environment variables and the overall component architecture. This article covers
only what differs on Azure.

**Tools:** Azure CLI, `kubectl`, and Helm.

**Azure prerequisites:**

* A subscription with quota for at least **8 vCPU** in your chosen region for a production-shaped
  cluster. Check both your quota **and** your subscription's permitted VM SKU list, which are
  separate restrictions — a size can be blocked even when quota is available.
* Permission to create role assignments, not just resources. `Contributor` alone is insufficient;
  you need `Owner`, or `Contributor` plus `User Access Administrator`.
* A Draftable product key, and outbound HTTPS access to `draftable.com` for licence activation.

<Tip>
  Avoid **B-series** burstable VM sizes. Their CPU credit model causes comparison times to degrade
  unpredictably once credits are exhausted, which is very difficult to diagnose after the fact.
</Tip>

## Azure-specific configuration

Six things differ from a generic Kubernetes deployment. The first will stop your deployment working
entirely and is very hard to diagnose, so start there.

### 1. Load balancer health probes

<Warning>
  **This will silently break your deployment and the symptom points you in the wrong direction.**
</Warning>

The `ingress-nginx` Helm chart sets `appProtocol: http` on the controller's Service ports. The Azure
cloud provider reads that and creates an **HTTP** health probe against `/` rather than a TCP probe.
Other ingress controllers may behave the same way, so check yours if you are not using
`ingress-nginx`.

Your ingress controller answers `/` with a 404 when no ingress rule matches the probe's request,
because the probe sends no `Host` header. Azure load balancer health probes treat **only HTTP 200**
as healthy. Every node is therefore marked unhealthy and the load balancer silently discards all
inbound traffic.

**The symptom is a connection timeout from the internet while the cluster is completely healthy
internally.** If you are using Let's Encrypt, certificate issuance fails with
`Timeout during connect (likely firewall problem)`, which naturally sends you to inspect network
security groups. The network security group is not the problem.

Fix it with an annotation on the ingress controller Service:

```yaml theme={null}
controller:
  service:
    annotations:
      service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path: /healthz
```

`/healthz` is served on the controller's data ports and returns 200.

To confirm what your probe is actually configured to check:

```bash theme={null}
NODE_RG=$(az aks show -g <resource-group> -n <cluster> --query nodeResourceGroup -o tsv)
az network lb probe list -g "$NODE_RG" --lb-name kubernetes \
  --query "[].{protocol:protocol, port:port, path:requestPath}" -o table
```

### 2. The shared NFS StorageClass

```yaml theme={null}
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: azurefile-premium-nfs
provisioner: file.csi.azure.com
parameters:
  protocol: nfs
  skuName: Premium_LRS
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: Immediate
mountOptions:
  - nconnect=4
  - actimeo=30
  - noresvport
  - rsize=1048576
  - wsize=1048576
```

`nconnect=4` opens multiple TCP connections to the share and is the single most useful tuning option
for this workload. `actimeo=30` caches attribute lookups and reduces round trips considerably; if you
see unexpected file consistency behaviour between services, reduce it.

The minimum provisioned size for an Azure Files Premium share is **100 GiB**, which is a billing
floor rather than a capacity recommendation.

```yaml theme={null}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: draftable-shared
  namespace: draftable
spec:
  accessModes: [ReadWriteMany]
  storageClassName: azurefile-premium-nfs
  resources:
    requests:
      storage: 100Gi
```

Mount this at `/srv/draftable` in the web, Celery worker, Celery beat and compare pods, and set:

```bash theme={null}
FILE_STORAGE_TYPE=local
DRAFTABLE_API_FILE_STORAGE_ROOT=/srv/draftable/data
```

### 3. Create the storage subdirectories before the compare service starts

The compare service watches `/srv/draftable/config` for licence changes and **fails to start if that
directory does not exist**. A freshly provisioned file share is empty, and nothing creates it for you.
On Docker Compose the directory is present inside the container image, so this problem is unique to a
new network share.

Add an init container to the compare deployment:

```yaml theme={null}
initContainers:
  - name: ensure-directories
    image: busybox:1.36
    command:
      - sh
      - -c
      - mkdir -p /srv/draftable/config /srv/draftable/data /srv/draftable/fonts
    volumeMounts:
      - name: shared
        mountPath: /srv/draftable
```

### 4. Compare service memory

<Warning>
  Allow **at least 4 GiB** for the compare service. We observed the container being terminated by the
  kernel (`OOMKilled`) with a 2 GiB limit, and once at 3 GiB, while comparing a 120-page PDF. Note that
  4 GiB is a headroom recommendation based on those observations — we did not validate it under load.
</Warning>

When the compare service is terminated, every comparison it was processing is lost. Callers see
either a comparison that never completes or an HTTP 500 on their next status request. **This looks
exactly like a storage or network fault**, so check for it first:

```bash theme={null}
kubectl get pod -n draftable -l app=compare \
  -o jsonpath='{.items[*].status.containerStatuses[*].lastState}'
```

### 5. Database connection limits

Draftable holds **persistent database connections** by default: `DB_CONN_MAX_AGE` defaults to `-1`,
which means connections are kept open indefinitely, and each worker process maintains its own.

Under sustained API load, connection count grows and a database configured with too few connections
will start refusing them. The API returns HTTP 500 and the logs show:

```
FATAL: sorry, too many clients already
```

<Warning>
  On **Azure Database for PostgreSQL Flexible Server**, `max_connections` is determined by the SKU you
  choose. Per Microsoft's documentation a `B1ms` allows roughly 50 connections, which Draftable can
  exhaust. **Size your database SKU by connection count, not only by CPU and memory.**

  We encountered this on an in-cluster PostgreSQL instance rather than on Flexible Server, which we did
  not test. Verify the connection limit for whichever SKU you select.
</Warning>

If a larger SKU is not an option, set `DB_CONN_MAX_AGE` to a bounded value such as `300` to recycle
connections instead of holding them open indefinitely.

To check current usage:

```bash theme={null}
psql -h <host> -U <user> -d draftable \
  -c "select state, count(*) from pg_stat_activity
      where backend_type='client backend' group by state;"
```

### 6. Converter health probes

This applies to all Kubernetes platforms, but it is worth repeating because it produces a dramatic
failure. The document converter must use **TCP** probes, not HTTP. Its API returns 404 on every
health endpoint, and LibreOffice takes 60 to 90 seconds to initialise, so HTTP probes fail
permanently and the pod restarts hundreds of times.

```yaml theme={null}
readinessProbe:
  tcpSocket:
    port: 8080
  initialDelaySeconds: 90
  periodSeconds: 10
  failureThreshold: 6
livenessProbe:
  tcpSocket:
    port: 8080
  initialDelaySeconds: 120
  periodSeconds: 30
  failureThreshold: 3
```

`Office process died with exit code 81` in the converter log during startup is expected and not an
error.

## DNS and TLS

For a publicly reachable hostname without configuring your own DNS, Azure can assign a label to the
load balancer's public IP address, giving you `<label>.<region>.cloudapp.azure.com`:

```yaml theme={null}
controller:
  service:
    annotations:
      service.beta.kubernetes.io/azure-dns-label-name: your-label
```

That hostname resolves publicly, which is sufficient for Let's Encrypt HTTP-01 validation via
cert-manager. For production, point a CNAME for your own domain at the load balancer address and
either use cert-manager or supply your own certificate as a TLS secret.

Set `APP_BASE_URL` and `CSRF_TRUSTED_ORIGINS` to the final HTTPS URL, and keep `ALLOWED_HOSTS` broad
enough to accept the load balancer's IP-based health checks.

## Verification

Confirm the licence actually activated. The initialisation job **completes successfully even when
activation fails**, so a completed job is not proof:

```bash theme={null}
kubectl logs -n draftable job/web-init | tail -20
```

Then confirm all pods are running without restarts, and submit a comparison through the API. Files
should appear beneath `/srv/draftable/data` on the shared volume.

<Note>
  Draftable stores the activated licence in **Redis**. A copy is written to
  `/srv/draftable/config/license.json` only when that directory already exists, so on a newly
  provisioned file share you should not expect to find it — do not treat its absence as a failed
  activation.

  **Redis persistence is therefore mandatory, not optional.** If Redis loses its data, users are locked
  out until the initialisation job is re-run to reactivate the licence.
</Note>

## Troubleshooting

<AccordionGroup>
  <Accordion title="The site times out from the internet but the cluster looks healthy">
    Almost always the load balancer health probe issue described in
    [Load balancer health probes](#1-load-balancer-health-probes). Confirm by testing the ingress
    controller from inside the cluster — if that works, the fault is between the internet and the load
    balancer, not in your cluster.
  </Accordion>

  <Accordion title="Comparisons fail intermittently under load">
    Check these in order before suspecting storage:

    1. **Compare service terminated for memory** — see [Compare service memory](#4-compare-service-memory).
    2. **Database connections exhausted** — see [Database connection limits](#5-database-connection-limits).
    3. **Only then** consider file share throughput.

    Memory exhaustion, connection exhaustion and file share contention all produce the same symptom:
    intermittent, load-dependent failures that will not reproduce on demand. They are easy to mistake for
    one another.
  </Accordion>

  <Accordion title="Comparisons are slower than expected">
    Confirm your share uses **NFS**, not SMB, and that it is on the **Premium** tier. Check `nconnect=4`
    is present in your mount options. If your documents contain very large numbers of embedded objects,
    the per-file overhead described in [Storage performance](#storage-performance) may be inherent to
    this platform rather than a misconfiguration.
  </Accordion>

  <Accordion title="A pod cannot mount the shared volume">
    Confirm the PersistentVolumeClaim is `Bound` and reports `RWX` access mode. Premium file shares must
    be at least 100 GiB, and NFS is unavailable on the Standard tier — requesting NFS with
    `skuName: Standard_LRS` will fail to provision.
  </Accordion>

  <Accordion title="The compare service will not start">
    Check that `/srv/draftable/config` exists on the shared volume. See
    [Create the storage subdirectories](#3-create-the-storage-subdirectories-before-the-compare-service-starts).
  </Accordion>

  <Accordion title="Cluster creation fails on VM size">
    Your subscription's permitted SKU list is separate from your quota, and a size can be rejected even
    when quota shows capacity. The error message lists every permitted size for your subscription and
    region.
  </Accordion>
</AccordionGroup>

## Getting help

Contact [support@draftable.com](mailto:support@draftable.com) with pod logs, pod events, your
sanitised configuration, and your storage tier and protocol.

Please tell us you are on AKS with a network file share, as it changes how we approach diagnosis.
And please read the support boundaries in [What we tested](#what-we-tested-and-what-we-did-not)
first, so our answer is not a surprise.
