← Back to Blog

Dual-Wielding Git: A Public Blog with a Private, Encrypted Backend

My personal blog has been my digital garden for years. It's hosted on GitHub Pages, which means the entire repository is public. This is perfect for published articles. But what about the other half of writing? The messy drafts, the half-baked ideas, the private journal entries, the raw research notes—where do they live?

For a long time, my solution was simple and crude: a _content/drafts/ directory listed in my .gitignore. This worked, but it was a compromise. My private notes had no version control, no sync between machines, and no real place in the project they were destined for. I was essentially hiding them from Git, and by extension, from a robust workflow.

I wanted a system where my public blog and private notes could coexist in the same repository. The public content would remain public, but the private content would be version-controlled and securely encrypted before ever being pushed to GitHub. This would give me a single source of truth, synced across all my devices, without exposing my digital brain to the world.

After exploring a few options, I landed on a beautifully elegant solution: git-crypt.

The Architecture of a Hybrid Repo

The goal was to make encryption a transparent part of my Git workflow. When I'm writing in Obsidian or running my Node.js build scripts, I want to see my files as normal, decrypted plaintext. But when I run git push, any file in a designated "private" area should be encrypted before it leaves my machine.

Here were my hard requirements:

  1. Transparency: The encryption/decryption cycle must be automatic. I shouldn't have to run manual commands to lock and unlock files.
  2. Compatibility: It must not interfere with my local tools. My static site generator should see plaintext, and Obsidian should be able to read and write notes without a hitch.
  3. Git-Native: git status and git diff should work as expected. If I change a single word in an encrypted file, Git should know the file has changed, but not be bogged down by a completely different ciphertext every time.

This last point is what ruled out some modern alternatives. For example, using a clean/smudge filter with a tool like age is a popular pattern. However, many modern encryption tools produce non-deterministic ciphertext (the same input produces different output each time for security reasons). This is great for security, but it breaks git diff --quiet and similar checks, which my build scripts rely on to see if content has changed.

git-crypt solves this perfectly. It uses deterministic encryption (AES-256-CTR, to be specific), meaning a given file and key will always produce the same encrypted output. This keeps git status clean and my build scripts happy. It hooks directly into Git's own clean (encrypt on commit) and smudge (decrypt on checkout) filter system, making it completely transparent.

Here's a simplified view of the data flow:

                                  ┌──────────────────┐
                                  │   GitHub Repo    │
                                  │ (Encrypted Files)│
                                  └──────────────────┘
                                          ▲  │
                                git push  │  │ git pull/clone
                               (encrypt)  │  │ (decrypt)
                                          │  ▼
┌──────────────────┐   git commit   ┌──────────────────┐
│  Working Tree    ├────────────────►   Local Git DB   │
│ (Plaintext Files)│  (clean filter) │  (Encrypted)     │
└──────────────────┘   git checkout └──────────────────┘
      ▲ │            (smudge filter)
      │ └────────────────────────────┘

┌──────────────────┐
│  You & Your Apps │
│ (Obsidian, VSCode)│
└──────────────────┘

Implementation: A Step-by-Step Guide

Setting this up took less than ten minutes. It's the kind of tool that just works.

1. Installation and Initialization

First, I installed git-crypt using Homebrew.

brew install git-crypt

Then, inside my blog's repository, I initialized it.

git-crypt init

This simple command sets up the necessary hooks and configuration inside the .git directory. It's a one-time setup per repository.

2. The All-Important Key Backup

git-crypt works with a symmetric key by default. This key is stored within your local .git directory and is not checked into version control. If you lose this key, you lose access to your encrypted files forever. This was the undisputed "don't mess this up" step of the whole process.

I immediately exported the key to a safe location (my password manager and an encrypted external drive).

git-crypt export-key ~/git-crypt-my-blog.key

Treat this key like a password. Do not commit it to any repository.

3. Configuring What to Encrypt

Next, I needed to tell git-crypt which files to encrypt. This is done through a .gitattributes file in the root of the repository. This is a standard Git mechanism for defining attributes per path, and git-crypt leverages it beautifully.

I created a .gitattributes file with the following content:

# Encrypt everything in the private directory
_content/private/** filter=git-crypt diff=git-crypt

# Encrypt the Obsidian vault settings
_content/.obsidian/** filter=git-crypt diff=git-crypt

This configuration instructs Git to apply the git-crypt filter to two locations:

  1. _content/private/**: My new home for all private drafts, notes, and journal entries.
  2. _content/.obsidian/**: The settings directory for my Obsidian vault, which can contain sensitive info like plugin configurations.

After creating this file, I committed it to the repository. Any new file matching these patterns will now be automatically encrypted.

4. Migrating and Committing

With the configuration in place, I did the following:

  1. Created the new _content/private/ directory.
  2. Moved my old, git-ignored _content/drafts/ folder to _content/private/drafts/.
  3. Updated my .gitignore to remove the old exclusion for _content/drafts/.

Before:

# .gitignore
_content/drafts/

After:

# .gitignore
# No longer needed, as .gitattributes handles the private content.

Finally, I staged all the newly moved files and the updated .gitattributes and .gitignore files.

git add .gitattributes .gitignore _content/private
git commit -m "feat: Integrate git-crypt for private notes"
git push

And just like that, my private notes were version-controlled and pushed to GitHub, fully encrypted. If you browse my repository on GitHub, the files in _content/private/ are binary blobs. But on my local machine, they're just plain markdown files.

The Daily Workflow: Seamless and Invisible

The best part about this system is that my day-to-day workflow hasn't changed at all. I edit my private notes in Obsidian, I write public posts, and I commit my changes. git-crypt works silently in the background.

When I set up a new development machine, the process is simple:

  1. Clone the repository. At this point, all the private files will be encrypted binary blobs.
  2. Copy my backed-up key (git-crypt-my-blog.key) to the new machine.
  3. Run the unlock command:
git-crypt unlock ~/git-crypt-my-blog.key

Instantly, all the encrypted files are decrypted in my working tree, and I can get back to work. It's a single, one-time command per new machine.

Understanding Key Storage: Where Does the Key Actually Live?

One thing that confused me at first was the relationship between the exported key file and the one inside the repository. Here's the full picture.

On an Already-Configured Machine

After running git-crypt init, the symmetric key is stored inside your .git directory:

.git/git-crypt/keys/default    # 148 bytes, the actual key

This is the key that git-crypt uses automatically during every commit and checkout. You never need to reference it manually—it just works. Since .git/ is never pushed to the remote, this key stays local.

The Exported Key is Just a Backup

When you run git-crypt export-key, you're simply copying that internal key to an external location. The exported file is byte-for-byte identical to .git/git-crypt/keys/default. Its sole purpose is disaster recovery: setting up new machines, or restoring access if your local .git directory is lost.

My Key Management Strategy

After the initial setup, I:

  1. Exported the key with git-crypt export-key ~/git-crypt-yuxu.ge.key
  2. Saved it to 1Password as a secure document
  3. Deleted the local exported copy — it's redundant since the key already lives inside .git/
# After confirming the key is safely in your password manager:
rm ~/git-crypt-yuxu.ge.key

The key doesn't need to live in your home directory or any specific path. It only needs to exist in two places: inside .git/git-crypt/keys/default (automatic) and in your password manager (backup).

Setting Up a Fresh Machine

On a brand new machine with no prior access, the full recovery process is:

# 1. Install git-crypt
brew install git-crypt          # macOS
# apt install git-crypt         # Ubuntu/Debian

# 2. Clone the repo (encrypted files will be binary blobs at this point)
git clone [email protected]:geyuxu/yuxu.ge.git
cd yuxu.ge

# 3. Export the key from your password manager to a temp file
# (download from 1Password, or scp from another machine, etc.)

# 4. Unlock — all encrypted files instantly become plaintext
git-crypt unlock /path/to/git-crypt-yuxu.ge.key

# 5. Clean up the temp key file (it's now inside .git/)
rm /path/to/git-crypt-yuxu.ge.key

After step 4, the key is copied into .git/git-crypt/keys/default, and you're set. All future pull/push operations will automatically encrypt and decrypt. The temporary key file can be safely deleted.

Gotchas and Final Thoughts

This setup is fantastic, but there are a few things to keep in mind:

  • Key Management is Everything: I'll say it again: if you lose your symmetric key, your data is gone. Back it up securely in multiple locations.
  • Merge Conflicts: If you get a merge conflict in an encrypted file, Git will show you the conflict markers inside the ciphertext. It will look like gibberish. You must resolve these conflicts locally on a machine where git-crypt is unlocked, where you can see the plaintext.
  • Collaboration: My setup uses a simple symmetric key because I'm the only contributor. If you need to grant access to a team, git-crypt has excellent support for GPG, allowing you to add multiple trusted users with their own keys.
  • History: This process only encrypts files going forward. If you accidentally committed sensitive files in the past, their history remains in plaintext. You'd need to use a tool like git-filter-repo to scrub them from history. For me, this wasn't an issue since I was migrating from a .gitignore'd directory.

This was the undisputed "freebie" optimization of my personal knowledge management system—a pure win. I now have a single, unified repository for my entire writing process. My public articles and private notes live side-by-side, each with full version history, synced across my devices. All it took was one simple, powerful, and transparent tool to bridge the gap.