All work

Feb 2023 – May 2023Personal project

Published 5 min read

Word to PDF Converter

A Word-to-PDF conversion service built as five microservices on Kubernetes, with RabbitMQ queues for asynchronous processing, MongoDB GridFS for file storage and JWT authentication. Designed to absorb up to 50,000 conversion requests.

  • Kubernetes
  • Docker
  • RabbitMQ
  • MongoDB
  • MySQL
  • Python
  • Flask
  • JWT
independently scaled services
5
conversion requests handled via queueing
50,000
converter replicas consuming in parallel
4
Kubernetes architecture diagram: a client calls the gateway through an ingress; the gateway talks to the auth service and a RabbitMQ queue, which feeds four converter pods and four notification pods. MySQL stores users and MongoDB stores files.

Converting a Word document to PDF sounds trivial until many people do it at once. Conversion is slow and CPU-heavy: each file starts a LibreOffice process. If the web server converted files inline, a burst of uploads would tie up every request thread and time out.

I built this project to learn how to design around that problem the way production systems do. It splits the work into small services, puts a queue between “accept the file” and “do the work”, and lets Kubernetes scale each piece independently.

Five services, one job each

The diagram above shows the full deployment. Each service has its own Docker image, Kubernetes manifests (Deployment, Service, ConfigMap, Secret) and replica count:

Service Responsibility Replicas
Gateway The only public entry point, behind an NGINX Ingress. Handles login, upload and download. 2
Auth Checks credentials against MySQL, then issues and validates JWTs. 2
RabbitMQ The word and pdf queues, run as a StatefulSet with a persistent volume. 1
Converter Consumes the word queue and converts files with LibreOffice (unoconv). 4
Notification Consumes the pdf queue and emails the user their file ID. 4

Following one file through the system

  1. UploadGateway validates the JWT, stores the .docx in GridFS
  2. QueueMessage on the "word" queue
  3. ConvertA converter pod makes the PDF and stores it
  4. NotifyMessage on the "pdf" queue → email
  5. DownloadGET /download?fid=…

1. Upload: accept fast, defer the work

The gateway asks the auth service to validate the bearer token, then stores the uploaded file in MongoDB GridFS, which is built for files larger than a normal document’s size limit. It publishes a small message that points to the file. The response returns immediately; no conversion happens on the request path.

def upload(f, fs, channel, access):
    try:
        fid = fs.put(f)
    except Exception as err:
        return "internal server error", 500

    message = {"word_fid": str(fid), "pdf_fid": None, "username": access["username"]}

    try:
        channel.basic_publish(
            exchange="",
            routing_key="word",
            body=json.dumps(message),
            properties=pika.BasicProperties(delivery_mode=pika.spec.PERSISTENT_DELIVERY_MODE),
        )
    except Exception as err:
        fs.delete(fid)  # don't leave an orphaned file if the message never made it
        return "internal server error", 500

Two details matter here. Messages are persistent, so queued work survives a RabbitMQ restart. And if publishing fails, the stored file is deleted, so storage and queue never disagree.

2. Convert: workers that only acknowledge finished work

Each converter pod consumes from the word queue. A message is acknowledged only after the PDF is stored and the follow-up message is published. If conversion fails, the message is negatively acknowledged and goes back on the queue, so a crashed pod never loses a job.

def callback(ch, method, properties, body):
    err = to_pdf.start(body, fs_word_files, fs_pdfs, ch)
    if err:
        ch.basic_nack(delivery_tag=method.delivery_tag)
    else:
        ch.basic_ack(delivery_tag=method.delivery_tag)

channel.basic_consume(queue=os.environ.get("WORD_QUEUE"), on_message_callback=callback)

The conversion itself shells out to LibreOffice through unoconv, which handles real-world .docx formatting:

command = ['unoconv', '-f', 'pdf', '-o', temp_pdf_file_path, temp_word_file_path]
subprocess.run(command, check=True)

with open(temp_pdf_file_path, "rb") as pdf_file:
    pdf_id = fs_pdfs.put(pdf_file.read())

message["pdf_fid"] = str(pdf_id)
channel.basic_publish(exchange="", routing_key=os.environ.get("PDF_QUEUE"), body=json.dumps(message), ...)

Because RabbitMQ hands each message to one consumer at a time, adding throughput is just a matter of more replicas. A burst of uploads simply makes the queue longer, and the four converters work through it at their own pace instead of the gateway falling over.

3. Notify and download

The notification service consumes the pdf queue and emails the user over SMTP to say their file is ready. The user then downloads it from the gateway with the file ID, again behind JWT validation.

Authentication

The auth service checks credentials against MySQL and issues an HS256-signed JWT with a one-day expiry. Every other request is validated against it before the gateway touches any data:

def createJWT(username, secret, authz):
    return jwt.encode(
        {
            "username": username,
            "exp": datetime.datetime.utcnow() + datetime.timedelta(days=1),
            "iat": datetime.datetime.utcnow(),
            "admin": authz,
        },
        secret,
        algorithm="HS256",
    )

Running it on Kubernetes

Everything runs on a local Minikube cluster with the Ingress, dashboard and metrics-server add-ons. Configuration and credentials are injected through ConfigMaps and Secrets instead of being baked into images. The Ingress is tuned for large uploads and slow conversions: unlimited body size and 600-second proxy timeouts.

spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 8
  template:
    spec:
      containers:
        - name: converter
          image: uzyszn/converter
          envFrom:
            - configMapRef: { name: converter-configmap }
            - secretRef: { name: converter-secret }

What I’d do differently today

This was a learning project, and looking back I would harden it before letting real users near it:

  • Hash passwords. The auth service compares plaintext passwords; it should store bcrypt or Argon2 hashes.
  • Scale on queue depth. Replica counts are fixed. A KEDA autoscaler driven by RabbitMQ queue length would add converters only when work piles up.
  • Add a dead-letter queue. Today a file that always fails to convert is retried forever. A retry limit plus a dead-letter queue would surface it instead.
  • Clean up temp files on failure. A conversion error currently leaves the temporary .docx on disk.

What I took away

The core lesson was decoupling with a queue. Once “accept the request” and “do the expensive work” are separate services joined by durable messages and careful acknowledgements, scaling and fault tolerance mostly come for free. Kubernetes then makes each piece independently scalable and replaceable.