Building a Seamless Writing Pipeline with Hedgedoc, Jekyll, and GitHub OAuth
Blog Posting and Markdown Web Editor
Building a Seamless Functional Blog Posting Pipeline Rube Goldberg machine with Hedgedoc, Jekyll, and GitHub OAuth
One of the goals for Scratchpad was simple:
let anyone on the team write a blog post without touching Git, YAML front‑matter, or terminal workflows—while keeping everything private and secure.
This post walks through how we built a full writing pipeline around Hedgedoc, Jekyll, OAuth2 Proxy, and a tiny publisher microservice that syncs posts into the blog automatically.
Why We Needed This
We wanted:
- A friendly, real‑time Markdown editor (Hedgedoc)
- Private access controlled through GitHub login
- Zero Git knowledge required from writers
- Automatic syncing → Jekyll
_posts/folder - A thin, maintainable backend without inventing a CMS
Hedgedoc checked most boxes, but we added authentication and a publishing workflow around it to make everything cohesive.
System Overview
Team Member → md.scratchpad.lol → OAuth2 Login → Hedgedoc
│
└── publisher microservice (8090)
│
└── writes markdown → project-blog/_posts/
│
└── Jekyll builds the site
Everything runs on a single VPS using Docker, Nginx, and LetsEncrypt SSL.
Authentication with GitHub + OAuth2 Proxy
Hedgedoc supports GitHub auth but doesn’t let us restrict who can log in.
That’s where oauth2-proxy comes in.
We configured oauth2-proxy to:
- use GitHub as the OAuth provider
- check a whitelist of approved emails (
/etc/oauth2-proxy/emails.txt) - serve our custom sign-in and error pages
- proxy requests to Hedgedoc internally
Custom templates live here:
project-blog/assets/auth-page/templates/
├── sign_in.html
└── error.html
Brand assets are served under:
scratchpad.lol/auth-assets/
The login screen now matches our Scratchpad branding with a clean, minimal UI.
Reverse Proxy Structure
We expose two subdomains:
| Purpose | Domain | Points To |
|---|---|---|
| Public blog | scratchpad.lol | Jekyll (port 4000) |
| Writer’s lab | md.scratchpad.lol | oauth2-proxy → Hedgedoc |
Nginx handles SSL, proxying, and static auth assets.
The Publisher Microservice
To avoid teaching every author how to commit Markdown into Jekyll, we wrote a small microservice using FastAPI.
Location:
project-blog/tools/publisher/
Responsibilities:
- Pull a note from Hedgedoc
- Convert it into valid Jekyll Markdown
- Inject required front‑matter
- Save it into
_posts/ - Commit changes to the repo (optional for automation)
Automatic Markdown Header Injection
Jekyll posts require YAML front‑matter like:
---
title: "My Post Title"
date: 2025-01-18 12:00:00
tags: []
---
The publisher service handles this automatically.
What the publisher does
- Takes the Hedgedoc title → becomes
title: - Generates
date: - Prepends YAML to the final Markdown file
Example output:
---
title: "Title From Hedgedoc"
date: 2025-01-18 12:00:00
tags: []
---
# Title From Hedgedoc
(rest of the Markdown)
Writers never touch YAML.
Publishing error (Edit 02/03/2026)
Seems from time to time, I get an error when publishing:
publish AVlDyzUhA
{
"detail": "HedgeDoc returned 403 for http://hedgedoc:3000/AVlDyzUhA/download"
}
Publishing error fixed (Edit 02/17/2026)
I fixed the error. The publishing tool (fast api server) didn’t have access to hedgedoc because it was being blocked by either nginx or hedgedoc was blocking. I changed the hedgedoc environment configuration for
CMD_ALLOW_ANONYMOUS: "true"
And now it works. Running the following command returns ok status:

In order to publish, you need to make sure your file is not Private. I suggest LOCKED as a default to make sure nobody edits your file but the publisher can see the hedgedoc file. See the image below for context:

Publish / Unpublish Commands
Two global CLI helpers were added.:
Publish
publish <note-id>
Unpublish
unpublish <note-id>
The note ID comes from the Hedgedoc URL:
https://md.scratchpad.lol/<note-id>
These commands hit the publisher API and sync the file automatically.
Directory Structure Recap
/root/scratchpad/
├── hedgedoc/ # Docker compose for Hedgedoc & OAuth
│ └── docker-compose.yml
├── project-blog/
│ ├── _posts/ # Auto-updated by publisher
│ ├── assets/auth-page/ # Branding + OAuth templates
│ └── tools/publisher/ # Sync microservice
└── nginx configs / SSL / etc…
The Result
The entire workflow is now seamless:
- Writer logs in via GitHub
- Opens Hedgedoc
- Writes
-
Runs:
publish <note-id>
And the blog updates instantly—no friction, no YAML, no Git.
Uploading Images
(Edit - Jan 13th 2025) In order to upload images, we need to enable in the docker-compose.yml file using the following ENVIRONMENT vars (I’m lazy just ran them straight in line). We also need to add a config.json specifically to specify the uploads path. And we do this by touching a file in the root hedgedoc dir, mounting the file as volume.
The settings say that only registered users may upload, the files will store on the filesystem (why we need the path), point to our config file with our added volume, and give all uploads readable permissions for jekyll and nginx to read the file.
docker-compose.yaml:
services:
...
hedgedoc:
image: quay.io/hedgedoc/hedgedoc:latest
environment:
# ...
CMD_ENABLE_UPLOADS: registered
CMD_IMAGE_UPLOAD_TYPE: filesystem
CMD_CONFIG_FILE: /hedgedoc/config.json
UPLOADS_MODE: "0755" # file perms so jekyll/nginx can read upload files.
volumes:
- /root/scratchpad/project-blog/_posts:/hedgedoc/_posts
- /root/scratchpad/project-blog/assets/uploads/public:/hedgedoc/_uploads
- ./config.json:/hedgedoc/config.json:ro
What does 0755 mean?
-
In one sentence, it means the owner can do everything; everyone else can read and access, but not modify.
-
The following is a more comprehensive breakdown:
0755 means:
0 = no special permissions
Owner (7 = rwx)
- can read
- can write
- can access / run
Group (5 = r-x)
- can read
- cannot write
- can access / run
Others (5 = r-x)
- can read
- cannot write
- can access / run
I will attempt to upload an image below. If it works, yay.

Edit (02/03/2026) - Uploading images bug
At some point, I was trying to upload images to Hedgedoc by drag and drop, and they wouldn’t upload. Hm. Annoying. First I inspected the webpage console, and immediately saw an error:
inline-attachment.js:274
POST https://md.scratchpad.lol/uploadimage 413 (Content Too Large)
m.uploadFile @ inline-attachment.js:274
m.onDrop @ inline-attachment.js:400
(anonymous) @ codemirror.inline-attachment.js:83
Which basically means my file too big. The file was 1MB so if that is too bag, we are cooked. Hedgedoc actually supports for sure 100MB out the box, so something is up.
That really leaves one place for this issue to exist. Our reverse proxy is the bottleneck for file uploads, limiting the max body. We can solve this issue by increasing the max body to 25mb in the nginx sites-available file:
/etc/nginx/sites-available/scratchpad
server {
listen 443 ssl http2;
server_name md.scratchpad.lol;
# need to increase nginx max upload file size to upload to hedgedoc.
client_max_body_size 25m;
//.... other stuff
}