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.
apt update && apt install -y curl gnupg vim ufw
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
mkdir ~/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 PDFPORT = 3100
npm 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.apt 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
md2pdfapi.owolf.com {
reverse_proxy localhost:3100
}
sudo systemctl reload caddy
Caddy issued a Let’s Encrypt cert and started serving HTTPS.
npx shadcn-ui@latest init
"use client";
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.createElement("a");
a.href = url;
a.download = "document.pdf";
a.click();
window.URL.revokeObjectURL(url);
};
return (
<div className="space-y-4 max-w-xl mx-auto">
<Textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
rows={12}
/>
<Button onClick={handleDownload} disabled={loading}>
{loading ? "Generating…" : "Download PDF"}
</Button>
</div>
);
}
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:
const 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: