Jekyll Secret Posts

Jekyll plugin for share-only URL secret posts

Feb 2026

Jekyll Secret Posts

TL;DR

  • A Jekyll plugin that provides private posts through a share-only URL approach.
  • Built to share private content on a blog where only people who know the URL can access it.
  • Supports hash-based permalinks, sitemap/search engine exclusion, and simple integration.


Planning

Background

Resume Image

These days, I have been writing my resume for job applications. I am trying to capture my career, projects, and overall experience so far, and I started thinking about how to fit everything into a single resume.

I cannot put every project detail into the resume itself. If I did that, it would easily exceed 10 pages. But if I remove those details, I cannot properly show the problem-solving process.

So I chose to create separate external documents for the details: keep the resume concise and explain deeper experience in external documents.

Then the question became: how should I create those external documents?

Creating separate files again did not feel much different from stuffing content into the resume, and I did not like the design of Notion or Google Docs.

Blog Projects Page

At that point, I looked at my own blog. The design was good, I could freely customize the UI, and anyone could access it immediately if I shared a URL, so it seemed like the best alternative.

However, it could be problematic to publish career experiences that contain company software architecture and domain context openly on the blog, so I could not use it as it is.

I could simply hide specific posts from list pages, but because the topic is sensitive, I wanted a more robust and scalable approach.

Google Drive Link Popup

drive.google.com/drive/folders/1FsvTO123Bb3mSQu456HwSAdpD00mo6tM?usp=sharing

While thinking about options, I noticed how Google Drive sharing links work.

Anyone who knows the URL can access it, but for someone who does not know it, discovering the exact URL is mathematically close to impossible: a share-only URL.

This matched my requirements exactly, so I decided to build a Jekyll plugin that automates share-only URL generation.


Goals

1. Share-Only URL

Hidden articles must be accessible only to people who know the URL.

Therefore, hidden articles should not only be excluded from article lists but also not appear in the sitemap, and they must prevent search engine indexing.

Also, no matter how well they are hidden, it is meaningless if URLs can still be inferred or brute-forced. So, as in Google Drive’s case, they need unguessable random strings.


2. Smooth Integration

I had struggled with environment setup in other Jekyll plugins before, so in this plugin I wanted to provide a smooth developer experience.

So integration should finish in one minute by just reading the README, and custom configuration should expose only the necessary features through a minimal interface.

Also, many Jekyll sites including my blog are built with multiple plugins, so I needed compatibility with other plugins to achieve smooth integration without complex setup.


Development

Tech Stack

Built for Ruby 2.7+ and Jekyll 4.x environments.

Because this plugin must integrate into Jekyll websites, I excluded additional dependencies beyond Jekyll and implemented it using only the Ruby standard library.

For testing and linting, I chose RSpec and RuboCop, which are widely used in the Ruby ecosystem.


Architecture

flowchart LR subgraph INPUT["Input"] config["_config.yml"] env["ENV"] md["_secret/"] end Config["Config"] UrlTokenizer["UrlTokenizer"] Hooks["Hooks"] Generator["Generator"] subgraph OUTPUT["Output"] secret["Secret posts"] redirect["Redirect"] end config --> Config env --> Config md -->|"documents"| Hooks Config -->|"settings (salt, token_length)"| UrlTokenizer Config -->|"settings"| Hooks Config -->|"settings"| Generator UrlTokenizer -->|"token"| Hooks UrlTokenizer -->|"token"| Generator Hooks -->|"permalink, noindex"| secret Generator -->|"redirect page"| redirect

This plugin is broadly composed of four modules: Config, UrlTokenizer, Hooks, and Generator.


Config

flowchart LR subgraph INPUT["Input"] yaml["_config.yml\nsecret_posts"] baseurl["baseurl"] env["JEKYLL_SECRET_SALT"] end subgraph CONFIG["Config"] read["Read & validate"] fallback["Default fallback"] normalize["Normalize"] end subgraph OUTPUT["Output"] s_dir["source_dir"] coll["collection_name"] prefix["url_prefix"] salt["salt"] layout["secret_index_layout"] redirect["redirect_url"] len["token_length"] list["list_urls"] end yaml --> read baseurl --> read env --> read read --> fallback fallback --> normalize normalize --> s_dir normalize --> coll normalize --> prefix read --> salt normalize --> layout normalize --> redirect normalize --> list

Config reads Jekyll site settings, custom settings from _config.yml, and the salt environment variable (JEKYLL_SECRET_SALT) to provide global configuration values referenced throughout the entire build lifecycle.

All custom settings are optional, and only minimal settings compatible with Jekyll defaults are provided, such as the secret collection’s path and identifier and the URL prefix where secret posts are exposed.

For details, please refer to README.md.


UrlTokenizer

flowchart LR subgraph IN["Input"] c["Config"] lbl["collection_label"] pth["relative_path"] end subgraph TOK["UrlTokenizer"] t["token_for"] end OUT["token"] c --> t lbl --> t pth --> t t -->|"salt + label + path → SHA256 → trim"| OUT

UrlTokenizer hashes the secret collection identifier (collection_name) and relative path (source_dir) to generate a fixed-length hex token used in URLs.

If the hash is computed without a salt, it is vulnerable to inference-based brute-force attacks, so using salt is recommended for security in production.

A SHA-256 hash is computed with salt, and part of the result is used as the token. The same path always produces the same token, so as long as the salt is the same, the same permalink is guaranteed across environments.


Hooks

flowchart TB subgraph HOOKS["Hooks"] direction TB hook1["site:after_init"] hook2["documents:post_init"] hook3["documents:post_render"] end subgraph ACTION1["register_secret_collection"] a1_in["site"] a1_proc["Add secret collection\nRemove from exclude"] a1_out["site.config"] end subgraph ACTION2["apply_secret_permalink"] a2_in["doc"] a2_proc["Check secret doc\nGet token from UrlTokenizer\nSet permalink, sitemap"] a2_out["doc.data"] end subgraph ACTION3["inject_noindex"] a3_in["doc.output"] a3_proc["Check secret doc\nInsert noindex meta"] a3_out["doc.output"] end hook1 --> ACTION1 hook2 --> ACTION2 hook3 --> ACTION3 a1_in --> a1_proc --> a1_out a2_in --> a2_proc --> a2_out a3_in --> a3_proc --> a3_out

Hooks implements the plugin’s core behavior by creating the secret collection and handling search exclusion, and it runs three times following the Jekyll build lifecycle.

After site initialization - register the secret collection in Jekyll collections

After document initialization - call UrlTokenizer, generate URL permalink for documents in the secret collection, and set sitemap exclusion

After rendering - insert the robots meta tag into rendered HTML of secret collection posts to prevent search engine indexing


Generator

flowchart LR subgraph INPUT["Input"] site["site"] end subgraph GEN["Generator"] a["add_secret_index_page"] l["log_secret_urls"] end subgraph OUT["Output"] page["Redirect page - index.html"] urls["URL list"] end site --> a site --> l a --> page l -->|"if list_urls"| urls

Generator manages how secret document URLs are accessed and runs before rendering during the Jekyll build lifecycle.

During build, Jekyll does not automatically generate index.html directly under the secret document path (_site/s/); it only generates individual documents such as _site/s/<token>/index.html.

If a web server supports directory listing (Apache - mod_autoindex, Nginx - autoindex), accessing the secret path /s/ can expose URLs of child documents that should remain hidden.

To prevent this, Generator creates a redirect index.html page and adds it to the secret path. This ensures that access to the secret path is automatically redirected to the configured path (redirect_url), blocking URL leakage through directory listing.

In addition, if list_urls is enabled, Generator logs secret collection URLs during build. The design considerations behind this feature are explained later in the troubleshooting section.


Jekyll Lifecycle

flowchart LR subgraph LIFECYCLE["Jekyll Lifecycle"] direction LR s1["Init"] s2["Read"] s3["Generate"] s4["Render"] s5["Write"] end s1 --> s2 --> s3 --> s4 --> s5 h1["after_init\nregister_secret"] h2["post_init\napply_permalink"] g["Generator"] h3["post_render\ninject_noindex"] s1 -.-> h1 s2 -.-> h2 s3 -.-> g s4 -.-> h3

The plugin runs sequentially across four points in the Jekyll build lifecycle: Init, Read, Generate, and Render.

Init

Right after the Init stage, the site:after_init hook runs. Before collection settings are finalized, it registers the secret post collection in collections, and if the source directory is included in exclude, it removes it to ensure Jekyll reads the _secret/ directory.

Read

Whenever a document object is created during build, the documents:post_init hook is called. It sets the URL of documents in the secret collection to a hashed permalink.

Generate

In the Generator stage, the plugin’s internal Generator runs. It adds a redirect index.html under the URL prefix path and prints the secret post URL list in build logs depending on configuration.

Render

After document rendering, when each document’s HTML has been generated, the documents:post_render hook runs. It inserts a noindex meta tag at the top of secret collection document HTML to prevent search engine indexing.


Troubleshooting

1. How to Check Secret Post URLs

Because secret URLs are hash-derived and cannot be predicted, users cannot know those URLs unless they directly inspect built files under _site.

To solve this, I added a feature to print URLs to build logs.

However, if the same logs are printed in unsafe environments such as CI/CD or external servers, secret URLs can leak. So I designed it with a restriction: output is enabled only when the user manually turns it on in a local development environment.


2. Jekyll Plugin Conflicts

Jekyll plugins reference shared objects such as collections, documents, permalinks, and rendered HTML simultaneously during build. Therefore, when developing a plugin, concurrency-aware design is needed to avoid conflicts with other plugins.

This plugin also considered conflict prevention during development. Here are the conflict cases that were resolved.

Conflict Type Cause Resolution
Path conflict Existing collection name/path duplication Prevent overwrite and support secret collection name/path settings
Duplicate permalink modifications Multiple plugins modify the same permalink Limit operation scope to the secret collection
Sitemap generation jekyll-sitemap plugin collects sitemap in the Generator stage Set doc.data["sitemap"] = false on secret documents in documents:post_init hook before Generator


Result

GitHub Repository Preview Image Gem Version

The plugin I developed has been publicly released on GitHub and RubyGems. You can try it in the project GitHub Repository.

This very blog you are reading also applies the plugin. Somewhere on this blog, at a URL only I know, there are posts containing career experience details that supplement my resume.

Going forward, I plan to keep using it myself and continue adding features as I identify needs. Contributions are always welcome!