Validate
The extension is checked, then the upload is read for the OOXML ZIP signature. Bad files never reach the converter.
.docx · PK\x03\x04Open source · Self-hosted · DOCX → PDF
DocMorph is a stateless HTTP microservice that converts DOCX documents to PDF using headless LibreOffice. Docker-ready, concurrency-safe and built to do one thing well.
01 / The transformation
Nothing else gets in the way. Four deterministic stages, no hidden state, no queues you have to operate.
The extension is checked, then the upload is read for the OOXML ZIP signature. Bad files never reach the converter.
.docx · PK\x03\x04Every job gets its own temp directory and a dedicated LibreOffice user profile, so concurrent conversions never share state.
/tmp/lo-<uuid>/profilesoffice runs headless under a per-conversion timeout, while a semaphore bounds how many jobs run at the same time.
soffice --headless --convert-to pdfThe result must begin with the PDF header. Only then is it streamed back as application/pdf.
%PDF-1.702 / Small by design
A small surface is easier to trust. Every feature DocMorph does not have is a thing that cannot break.
Nothing to migrate, back up, or keep in sync. State lives in the request and ends with the response.
The request is the job. Success or failure, it is over as soon as the HTTP response is written.
Optional API keys from a comma-separated environment variable. That is the entire user model.
One public /health endpoint reports pool load, limits and auth. Operate it with the tools you already have.
03 / Guardrails
Every request is checked, bounded and timed. Failures are explicit, never silent.
Only .docx is accepted. Anything else is rejected up front.
endsWith(".docx")The ZIP/OOXML signature is verified — not just the file name.
PK\x03\x04Uploads are capped before they reach the converter.
MAX_FILE_SIZEThe generated file must start with the PDF header before it is streamed.
%PDFWhen API_KEYS is set, every route except /health requires a key.
x-api-keyA fixed window per client IP. /health is excluded so probes stay cheap.
60 / minA stalled LibreOffice process is killed instead of holding a slot forever.
CONVERT_TIMEOUT_MSDefaults shown. Every value is an environment variable — override it on the command line or in your compose file.
04 / API in action
If you can POST a file, you can use DocMorph. One multipart field, one PDF back.
curl -o out.pdf \
-H "x-api-key: your-secret-key" \
-F "file=@document.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" \
http://localhost:8080/convert
import { readFile, writeFile } from 'node:fs/promises';
const docx = await readFile('document.docx');
const form = new FormData();
form.append('file', new Blob([docx], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}), 'document.docx');
const res = await fetch('http://localhost:8080/convert', {
method: 'POST',
headers: { 'x-api-key': process.env.DOCMORPH_API_KEY! },
body: form,
});
if (!res.ok) throw new Error(`convert failed: ${res.status}`);
await writeFile('out.pdf', Buffer.from(await res.arrayBuffer()));
import os
import requests
with open('document.docx', 'rb') as f:
res = requests.post(
'http://localhost:8080/convert',
headers={'x-api-key': os.environ['DOCMORPH_API_KEY']},
files={'file': ('document.docx', f,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document')},
timeout=90,
)
res.raise_for_status()
with open('out.pdf', 'wb') as f:
f.write(res.content)
| Status | What it means |
|---|---|
| 401 | Missing or invalid API key |
| 412 | Missing file field or unsupported extension |
| 415 | File content is not a valid .docx (magic-byte check) |
| 429 | Rate limit exceeded (Retry-After + RateLimit-* headers) |
| 500 | Conversion failed |
Successful responses carry Content-Type: application/pdf and an inline filename sanitized to [A-Za-z0-9._-].
05 / Operability
Know what the service is doing without guessing.
GET /health is always public and excluded from rate limiting, so Docker and load-balancer probes never need credentials.
Active, queued and maximum concurrency are reported live, straight from the semaphore.
Max file size, rate-limit window and conversion timeout travel in the same payload.
Request-scoped key=value lines go to stdout and stderr. Pipe them anywhere.
06 / Self-host it
Your documents stay inside your network. The service stays out of your way.
ghcr.io/darcas/docmorph:latest is public. No build step, no toolchain, no fonts to install.
Pass API_KEYS to require x-api-key or Authorization: Bearer on every route except /health. Leave it unset for open access.
Map container port 8080 wherever your infrastructure expects it.
POST a .docx to /convert and stream the PDF back. That is the whole integration.
docker run -d --name docmorph \
-p 8080:8080 \
-e API_KEYS="your-secret-key" \
ghcr.io/darcas/docmorph:latest
name: docmorph
services:
api:
image: ghcr.io/darcas/docmorph:latest
restart: unless-stopped
environment:
API_KEYS: "your-secret-key"
MAX_CONCURRENT: "3"
ports:
- "8080:8080"
Ship the endpoint
Clone it, run it in a container, and let it do exactly what it says on the box.