2025-04-15 Web Development, Programming, Productivity
Markdown-to-PDF API with a Vercel Frontend and a DigitalOcean Backend
By O. Wolfson
I recently built a secure, full-stack Markdown-to-PDF converter using:
- A Vercel-hosted frontend (Next.js 15, App Router, Server & Client Components)
- A Node.js backend (Express + Puppeteer) hosted on a DigitalOcean droplet
- Caddy to handle HTTPS with Let’s Encrypt
- A custom subdomain:
md2pdfapi.owolf.com
View the live demo at md2pdf.owolf.com/
Here’s a step-by-step breakdown of what I did.
🛠️ Step 1: Set Up the Backend on a DigitalOcean Droplet
🔹 1. Create a Droplet
- Chose a basic Ubuntu droplet (1 vCPU / 1 GB RAM)
- Connected via DigitalOcean's web console
🔹 2. Installed Required Software
bashapt update && apt install -y curl gnupg vim ufw curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt install -y nodejs
🔹 3. Created My Project Folder
bashmkdir ~/md-to-pdf && cd ~/md-to-pdf
npm init -y
npm install express puppeteer markdown-it markdown-it-container cors
📄 Step 2: Created the API with Puppeteer + Express
I wrote a Node script (server.js
) that:
- Parses markdown using
markdown-it
- Uses Puppeteer to render HTML and export it as a PDF
- Exposes a POST
/api/convert
route that accepts markdown and returns a PDF
bashPORT = 3100
🔁 Step 3: Ran It with PM2
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.
🌍 Step 3.5: Point a Subdomain to the Droplet (A Record in Vercel)
Since my domain owolf.com
is managed through Vercel DNS, I created a subdomain to point to my API server.
🔹 1. Opened the Vercel dashboard → Domains → owolf.com
🔹 2. Added a new A record:
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.
🌐 Step 4: Added a Domain and HTTPS with Caddy
🔹 1. Pointed a subdomain (md2pdfapi.owolf.com
) to the droplet IP using an A record.
🔹 2. Installed and configured Caddy:
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
🔹 3. Caddyfile:
caddyfilemd2pdfapi.owolf.com { reverse_proxy localhost:3100 }
bashsudo systemctl reload caddy
Caddy issued a Let’s Encrypt cert and started serving HTTPS.
🖥️ Step 5: Built the Frontend on Vercel with Next.js 15
🔹 1. Created a new app with App Router and Server Components
🔹 2. Installed ShadCN for UI components
bashnpx shadcn-ui@latest init
🔹 3. Added a client component:
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.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>
);
}
🔒 Step 6: Added Security Measures
🛡️ Rate Limiting the API
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.
✅ Final Result
- The Vercel frontend sends markdown to
https://md2pdfapi.owolf.com/api/convert
- The droplet returns a freshly rendered PDF
- Fully HTTPS-secured
- Fully serverless on the frontend, persistent on the backend
📌 Takeaways
This was a great project for practicing:
- Multi-host architecture (Vercel + DigitalOcean)
- Caddy for secure reverse proxying
- Clean API separation
- Server-side rendering with Puppeteer
You’re welcome to reuse this setup for any static-to-PDF workflow, including:
- Invoices
- Blog post exports
- Reports or templates
- Serverless tools with an on-demand rendering backend