Case study

A Word-to-PDF Microservice Without Paid Libraries

Several .NET applications needed dependable document conversion. I moved that awkward platform concern into one small Node.js service, put LibreOffice beside it in Docker, and gave every application the same HTTP contract.

Node.jsLibreOfficeDockerMicroservice
Role
Backend developer
Where
Vertex Special Technologies
Used by
Multiple .NET projects
01

The problem

Generating a PDF from a DOCX sounds like file conversion. It is really document rendering. Fonts, page breaks, tables, headers and images all have to survive the trip. Writing that renderer ourselves would have meant rebuilding part of a word processor. Commercial libraries solved it, but introduced a licence cost into every project that needed the feature.

An external conversion API was another option, but it would send client documents outside our infrastructure and make availability, limits and pricing somebody else’s decision. We already had a capable rendering engine in LibreOffice. The useful question was how to make it behave like ordinary backend infrastructure.

02

The boundary

I kept LibreOffice out of the .NET applications entirely. A Node.js service accepts a DOCX as multipart form data, writes it to a temporary working area, asks LibreOffice to convert it in headless mode, returns the resulting PDF with the correct content type, and removes the temporary files.

One contract, one moving part

The callers only know that they send a document and receive a PDF. They do not know where LibreOffice is installed, which command starts it, or where it writes output. That let the same service sit behind several .NET projects instead of repeating operating-system code in each one.

03

The container is part of the implementation

The service depended on more than Node.js. LibreOffice, font packages and file permissions all affected the result. Docker made those dependencies explicit and repeatable across development, staging and production.

  • An Alpine-based Node image kept the base smaller than a general-purpose operating system image
  • LibreOffice and required fonts were installed during the image build
  • The process ran as a non-root user with ownership of only its working directory
  • The application exposed a narrow HTTP endpoint instead of exposing the conversion command itself
04

The failure that shaped it

My first implementation let LibreOffice choose where to put the generated file. Inside the container that meant permission errors, uncertain file locations and possible name collisions. A successful process did not always mean the service could find or safely return the PDF.

The fix was small: always provide an explicit output directory. The lesson was larger: temporary-file ownership is part of the API, even when callers never see it.

The service now controls the input path, output path and cleanup lifecycle. It checks that the expected PDF exists after the process exits, handles failed conversions as failed requests, and cleans up both success and error paths.

05

Performance and failure modes

Cold versus warm

Starting LibreOffice dominated the first request at roughly three seconds in testing. Warm conversions were around half a second for the documents used during development. Those are observations, not a promise for every document.

Bounded concurrency

Conversion is CPU and memory work, not ordinary asynchronous I/O. Testing reached roughly eight to ten simultaneous conversions. Beyond that point, a queue and explicit worker limit would be safer than allowing every request to start a process.

Input validation matters too. The endpoint should limit file size, reject unsupported types, generate server-owned filenames and never construct a shell command from a client-provided path. Container isolation reduces the blast radius, but it does not replace validation.

06

Result and next version

The result was one reusable conversion service shared by multiple .NET applications, with no per-project commercial-library fee and the same runtime from a developer machine to production.

If volume justified another iteration, I would put uploads in object storage, enqueue conversions, run a fixed number of workers and return job status asynchronously. For the traffic we had, a synchronous service was the smaller and more honest design.