Stateless HTTP microservice that converts .docx documents to PDF using headless LibreOffice. Single-purpose, container-first, and safe for concurrent use — upload a file, get a PDF back.
- One endpoint, one job —
POST /convertaccepts a.docxfile and streams backapplication/pdf. - Hardened uploads — extension check, OOXML/ZIP magic-byte validation, configurable size limit, and
%PDFassertion on the output. - Concurrency control — bounded conversion pool (semaphore) so LibreOffice never gets more work than it can handle.
- Optional API-key auth — when
API_KEYSis set, every route except/healthrequiresx-api-keyorAuthorization: Bearer. - Rate limiting — per-IP fixed window via
express-rate-limit(/healthexcluded). - Observable —
GET /healthreports status, pool load, limits, and whether auth is enabled.
docker run -d --name docmorph \
-p 8080:8080 \
-e API_KEYS="your-secret-key" \
ghcr.io/darcas/docmorph:latestOr with Docker Compose (see docker-compose.dist.yml):
docker compose up --buildcurl -o out.pdf \
-H "x-api-key: your-secret-key" \
-F "file=@document.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" \
http://localhost:8080/convertimport { 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)All settings are environment variables:
| Variable | Default | Description |
|---|---|---|
API_KEYS |
(unset) | Comma-separated API keys; unset means open access |
CONVERT_TIMEOUT_MS |
60000 |
Per-conversion LibreOffice timeout |
MAX_CONCURRENT |
3 |
Max simultaneous conversions |
MAX_FILE_SIZE |
10485760 |
Max upload size in bytes (10 MiB) |
PORT |
8080 |
HTTP listen port |
RATE_LIMIT_MAX |
60 |
Max requests per window per IP (/health excluded) |
RATE_LIMIT_WINDOW_MS |
60000 |
Rate-limit window per client IP |
Public, unauthenticated, and excluded from rate limiting — safe for load-balancer and Docker healthchecks.
{
"status": "ok",
"authEnabled": true,
"concurrency": {
"active": 0,
"queued": 0,
"max": 3
},
"maxFileSize": 10485760,
"rateLimit": {
"max": 60,
"windowMs": 60000
},
"timeoutMs": 60000
}Multipart upload with a single field named file containing a .docx document.
- Auth (when enabled):
x-api-key: <key>orAuthorization: Bearer <key>, otherwise401. - Success:
200withContent-Type: application/pdfandContent-Disposition: inline; filename="<name>.pdf"(filename sanitized to[^A-Za-z0-9._-]). - Errors:
| Status | Meaning |
|---|---|
401 |
Missing or invalid API key |
412 |
Missing file field or unsupported file extension |
415 |
File content is not a valid .docx (magic-byte check) |
429 |
Rate limit exceeded (Retry-After + RateLimit-* headers) |
500 |
Conversion failed |
Requires Node.js >= 22 (see .nvmrc).
npm install
npm run dev # tsx watch, serves the API locally
npm run typecheck # tsc --noEmit
npm run build && npm start # compile src/ → dist/ and runConversion itself needs
soffice(libreoffice-core+libreoffice-writer), which exists only in the Docker image. Localnpm run devserves the routes, butPOST /convertwill fail withCONVERSION_FAILEDoutside Docker — verify viadocker compose up --build.
src/
├── index.ts # routes, auth, rate limiting, concurrency pool
├── convert.ts # headless LibreOffice (soffice) wrapper
├── concurrency.ts # semaphore bounding simultaneous conversions
└── logger.ts # request-scoped key=value stdout/stderr logging
Each conversion runs soffice --headless --convert-to pdf in an isolated temp directory with a dedicated LibreOffice user profile per job, so concurrent conversions never interfere.
This project is licensed under the MIT License. See the LICENSE file for details.
Made with ❤️ by Dario Casertano (DarCas).