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

bash
apt 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

bash
mkdir ~/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
bash
PORT = 3100

๐Ÿ” Step 3: Ran It with PM2

bash
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.

๐ŸŒ 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:

TypeNameValue
Amd2pdfapi157.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:

bash
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

๐Ÿ”น 3. Caddyfile:

caddyfile
md2pdfapi.owolf.com {
  reverse_proxy localhost:3100
}
bash
sudo 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

bash
npx 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:

js
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.

โœ… 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