April 15, 2025
O. Wolfson
I recently built a secure, full-stack Markdown-to-PDF converter using:
md2pdfapi.owolf.comView the live demo at md2pdf.owolf.com/
Hereโs a step-by-step breakdown of what I did.
bashapt update && apt install -y curl gnupg vim ufw curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt install -y nodejs
bashmkdir ~/md-to-pdf && cd ~/md-to-pdf
npm init -y
npm install express puppeteer markdown-it markdown-it-container cors
I wrote a Node script (server.js) that:
markdown-it/api/convert route that accepts markdown and returns a PDFbashPORT = 3100
bashnpm install -g pm2 pm2 start server.js --name md-to-pdf pm2 save pm2 startup
Now my backend runs continuously and restarts on reboot.
Since my domain owolf.com is managed through Vercel DNS, I created a subdomain to point to my API server.
owolf.com| Type | Name | Value |
|---|---|---|
| A | md2pdfapi | 157.230.39.117 |
This created:
md2pdfapi.owolf.com โ 157.230.39.117
Which is the public IP of my DigitalOcean droplet.
This DNS entry is required for Caddy to issue an HTTPS certificate for the subdomain using Letโs Encrypt.
md2pdfapi.owolf.com) to the droplet IP using an A record.bashapt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddy
caddyfilemd2pdfapi.owolf.com { reverse_proxy localhost:3100 }
bashsudo systemctl reload caddy
Caddy issued a Letโs Encrypt cert and started serving HTTPS.
bashnpx shadcn-ui@latest init
tsx"use client";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
export default function ConvertForm() {
const [markdown, setMarkdown] = useState("# Hello World");
const [loading, setLoading] = useState(false);
const handleDownload = async () => {
setLoading(true);
const res = await fetch("https://md2pdfapi.owolf.com/api/convert", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ markdown }),
});
setLoading(false);
if (!res.ok) return alert("Failed to generate PDF");
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.();
a. = url;
a. = ;
a.();
..(url);
};
(
);
}
To prevent abuse and protect server resources, I added basic rate limiting to the API using the express-rate-limit middleware.
This setup limits clients to 10 requests per minute per IP:
jsconst rateLimit = require("express-rate-limit");
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // limit each IP to 10 requests per minute
message: { error: "Too many requests. Please try again later." },
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter); // Apply to all routes
If a user exceeds the limit, they receive a 429 Too Many Requests response.
This keeps the service stable even under heavy traffic or by mistake-triggered loops.
https://md2pdfapi.owolf.com/api/convertThis was a great project for practicing:
Youโre welcome to reuse this setup for any static-to-PDF workflow, including: