Overview
I love the convenience of a monorepo for my homelab. Having my NixOS configs, blog, and custom apps in one place makes cross-project changes trivial. However, it creates a challenge: how do I share specific projects on GitHub without exposing my entire private infrastructure?
The solution I’ve landed on uses git subtree to split off specific directories into their own public repositories. In this post, I’ll show how I moved from manual syncing with a Justfile
to fully automated “Sync on Push” using OneDev
.
The Strategy: Git Subtrees
Unlike git submodule, which treats an external repo as a pointer, git subtree allows you to treat a subdirectory as a full-fledged git repository. You can push only that directory’s history to a remote, effectively “projecting” part of your monorepo onto a public GitHub repo.
Phase 1: Manual Sync with Justfile
Before automating it, I needed a reliable way to trigger these pushes manually. I use a justfile as a command runner. It handles the “plumbing” of adding the remotes and pushing the prefixes.
# Initial setup for a project
init:
gh auth setup-git
git remote add github-lsm https://github.com/MyTinyGitHub/lsm-tree.git
git remote add github-distributed-database https://github.com/MyTinyGitHub/distributed-database-rust.git
# Sync a specific project
sync-lsm:
git subtree push --prefix=apps/lsm-tree github-lsm main
sync-distributed-database:
git subtree push --prefix=apps/database github-distributed-database main
This works great for local development, but it requires me to have the correct SSH keys and permissions on my workstation, and I have to remember to run it.
Phase 2: Automation with OneDev CI/CD
To make this “zero-touch,” I integrated the sync into my OneDev buildspec. Now, whenever I push a change to the main branch of the monorepo, OneDev checks which paths were modified and triggers the corresponding GitHub sync.
The OneDev Buildspec
I use a Step Template in OneDev to keep the configuration DRY (Don’t Repeat Yourself). The template handles the git overhead, while the specific jobs provide the directory prefix and GitHub project name.
# Step Template for Sync
- name: GitHub Sync Template
steps:
- type: CommandStep
name: Subtree Push
image: alpine/git
commands: |
apk add --no-cache git-subtree
# Add the remote using a GITHUB_TOKEN secret
git remote add github https://MyTinyGitHub:[email protected]/MyTinyGitHub/@param:[email protected]
git push github $(git subtree split --prefix=apps/@param:app_directory@):main
envVars:
- name: GITHUB_TOKEN
value: "@secret:GITHUB_TOKEN@"
# Job Trigger
- name: Sync Distributed Database
steps:
- type: UseTemplateStep
templateName: GitHub Sync Template
paramMatrix:
- name: app_directory
values: ["database"]
- name: github_project
values: ["distributed-database-rust"]
triggers:
- type: BranchUpdateTrigger
branches: main
paths: apps/database/**
Why This Matters
This setup gives me the best of both worlds:
- Atomic Commits: I can update a database library and the app that uses it in a single monorepo commit.
- Privacy: My internal NixOS secrets and infrastructure configs stay in my private monorepo.
- Open Source: My apps are automatically mirrored to GitHub, keeping my public profile active without extra effort.
Challenges: Deep History and Performance
One thing to watch out for with git subtree is that it has to recalculate the history for the prefix on every push. In a monorepo with thousands of commits, this can become slow.
OneDev handles this by running the sync in a dedicated container, so it doesn’t block my local workflow. However, if the monorepo grows massive, I might eventually switch to git-filter-repo or a more specialized tool for high-performance history splitting.
Gotcha: Shallow Clones (depth = 1) in CI
A major ‘gotcha’ occurs if your CI checkout uses a shallow clone. For example, I set a depth parameter to speed up checkout while optimizing pipeline speed, and hit this exact issue.
Because the CI container lacks the full parent commit history, git subtree cannot accurately rebuild the subdirectory’s git graph. It ends up generating completely different commit hashes than those already on the public remote, causing standard fast-forward pushes to be rejected ([rejected] (fetch first)).
The solution is to force the CI pipeline to use a full clone instead of a shallow one.
A Note on Security: Token Exposure
In the example above, I’ve embedded the $GITHUB_TOKEN directly in the remote URL for simplicity. While functional, this is a “gotcha” waiting to happen: Git often echoes the remote URL in error messages or logs, which could leak your token.
For a more robust production setup, consider using a credential helper or GIT_ASKPASS to inject the token into the Git process without it ever appearing in the URL string or your shell history.
Conclusion
Automating your monorepo sync is the final step in making a private-first homelab feel like a professional development environment. Whether it’s a Rust database engine or this very blog, I know that my public presence is always in sync with my actual work.
This completes my initial series on homelab automation! We’ve covered the Hugo pipeline , NixOS deployments , and now the monorepo synchronization that ties it all together.