Vendoring Hugo themes with Nix and Git
tl;dr: ¶
- I forked the git repo for my Hugo theme and pushed my feature to a branch
- I refactored the blog repo’s Nix code to be multi-file and flake-based
- I switched my Nix-vendored theme to use this git repo/branch
- I made it possible to use the git repo checked out locally for rapid prototyping
- I retained the possibility to use the Nix-vendored theme (same branch, but committed and pushed)
- I found a way to easily deploy using either pathway without changing files (
--theme,just)
Context ¶
I migrated this website to Hugo in February 2025:
It explains how to bootstrap Hugo using Nix, how to add a theme, a devShell, a justfile, how to do simple SSH-based deployment, and finally how to add a virtual host to an nginx using Let’s Encrypt on your NixOS webserver.
After having run this blog for a year, I want to make some small changes to the theme:
commit f5c293ea884660fc5be85d1102e259b1d5b05989 (HEAD -> feat/hovering-heading-anchor-links, origin/feat/hovering-heading-anchor-links) Author: Simon Shine <simon@simonshine.dk>
Date: Sun Feb 8 16:02:39 2026 +0100
feat(theme): add hover anchor links to headings
Add paragraph sign (¶) anchor links that appear on heading hover,
making it easy to link to specific sections of articles.
- Add render-heading.html hook to generate heading anchors
- Style anchors to appear on hover with smooth transition
- Position anchors to the left of headings using theme colors
diff --git a/assets/css/_extra.scss b/assets/css/_extra.scss
index 8eaa4d4..1596bce 100644
--- a/assets/css/_extra.scss +++ b/assets/css/_extra.scss
@@ -1,2 +1,27 @@
// Do not add any CSS to this file in the theme sources.
// This file can be overridden to add project-specific CSS.
+
+// Heading anchor links
+.post-content {
+ h1, h2, h3, h4, h5, h6 {
+ position: relative;
+
+ .heading-anchor {
+ position: absolute;
+ left: -1.2em;
+ padding-right: 0.5em;
+ text-decoration: none;
+ color: $primary-color;
+ opacity: 0; + transition: opacity 0.2s ease-in-out;
+
+ &:hover { + color: $primary-color;
+ }
+ }
+
+ &:hover .heading-anchor {
+ opacity: 1;
+ }
+ }
+}
diff --git a/layouts/_default/_markup/render-heading.html b/layouts/_default/_markup/render-heading.html
new file mode 100644
index 0000000..dae6b57
--- /dev/null
+++ b/layouts/_default/_markup/render-heading.html
@@ -0,0 +1,4 @@
+<h{{ .Level }} id="{{ .Anchor | safeURL }}">
+ {{ .Text | safeHTML }}
+ <a class="heading-anchor" href="#{{ .Anchor | safeURL }}" aria-label="Link to this section">¶</a>
+</h{{ .Level }}>
I think those changes might be valuable to the upstream, but I want to deploy them locally regardless.
Vendoring Hugo themes with git and Nix ¶
The process of making your own copy of 3rd party packages your project is called vendoring. Those copies are traditionally placed inside each project and then saved in the project repository. The Dfetch project has an excellent summary of software vendoring, what it is, why it’s helpful, costs and risks.
I’ll quickly summarize my options with git alone:
- I can copy the theme directory into my blog repo and commit it. Not doing that for above mentioned reasons.
- I can use a git submodule.
- I can use a git subtree.
- I can use a git subrepo.
But with Nix, I can make cross-repo references using fetchers such as fetchFromGitHub.
Copying the theme directory ¶
Hugo works so that you can pretty much make any theme change directly in your blog.
I’ve made such changes by adding so-called shortcodes; they’re not propagated to the theme.
So when I make another blog and want to re-use them, I have to copy those shortcodes along.
I have other changes I really want to stick to the theme because they’re styling, and I want to reuse them easily.
What a rookie will do is copy the theme repo into your blog’s repo and commit it entirely:
- (+) You can easily edit the theme for your blog.
- (÷) Changes won’t propagate to other blogs using the theme.
- (÷) You no longer get upstream updates to the theme by the original author.
- (+) You don’t care if the upstream disappears because you’re using a local copy.
I’m almost doing that in shell.nix:
{ pkgs ? import <nixpkgs> {} }:
let
hugo-theme = builtins.fetchTarball {
name = "hugo-theme-m10c";
url = "https://github.com/vaga/hugo-theme-m10c/archive/8295ee808a8166a7302b781895f018d9cba20157.tar.gz";
sha256 = "12jvbikznzqjj9vjd1hiisb5lhw4hra6f0gkq1q84s0yq7axjgaw";
};
in
pkgs.mkShellNoCC {
packages = [
pkgs.hugo
pkgs.just
];
shellHook = ''
[ -f hugo.toml ] || hugo new site . --force
mkdir -p themes
ln -snf "${hugo-theme}" themes/default
'';
}
The differences are:
- (+) I copy the theme directory at runtime and don’t commit it.
- (÷) I can’t easily edit the theme for my own blog.
- (+) I can re-use the theme, and I do get upstream changes.
- (÷) If the upstream disappears, my blog breaks.
I’d like to make changes to the theme for re-usability, and along the way avoid depending on a 3rd party git repository.
Revamping the Nix configuration ¶
Until now I’ve used only a shell.nix, inlining the theme derivation.
I like the simplicity, but since I’m going to modify the theme, I’d like a bit more structure:
[feng:~/Projects/simonshine.dk] [main] $ eza -lg *.nix
.rw-r--r-- 829 sshine users 8 Feb 16:05 flake.nix
.rw-r--r-- 530 sshine users 8 Feb 16:05 default.nix
.rw-r--r-- 275 sshine users 8 Feb 16:05 shell.nix
.rw-r--r-- 387 sshine users 8 Feb 16:05 theme.nix
My main reason for introducing a flake here is that I like to have a single entrypoint into my repo:
{
description = "The blog for https://simonshine.dk";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
perSystem = { config, self', inputs', pkgs, system, ... }:
let
theme = pkgs.callPackage ./theme.nix {};
site = pkgs.callPackage ./default.nix { inherit theme; };
shell = pkgs.callPackage ./shell.nix { inherit theme; };
in {
devShells.default = shell;
packages = {
theme = theme;
site = site;
default = site;
};
};
};
}
The default.nix serves as a derivation containing the generated site:
{ pkgs ? import <nixpkgs> {}
, theme ? pkgs.callPackage ./theme.nix {}
}:
pkgs.stdenv.mkDerivation {
pname = "simonshine-dk";
version = "0.1.0";
src = pkgs.lib.cleanSource ./.;
nativeBuildInputs = [ pkgs.hugo ];
buildPhase = ''
mkdir -p themes
ln -s ${theme} themes/default
hugo --minify
'';
installPhase = ''
cp -r public $out
'';
meta = with pkgs.lib; {
description = "The blog for https://simonshine.dk";
homepage = "https://simonshine.dk";
platforms = platforms.all;
};
}
The shell.nix is mostly the same, with the exception that the theme has been extracted:
{ pkgs ? import <nixpkgs> {}
, theme ? pkgs.callPackage ./theme.nix {}
}:
pkgs.mkShellNoCC {
packages = [
pkgs.hugo
pkgs.just
];
shellHook = ''
[ -f hugo.toml ] || hugo new site . --force
mkdir -p themes
ln -snf "${theme}" themes/default
'';
}
And the theme.nix is only different by living in its own file and by targeting my repo:
{ pkgs ? import <nixpkgs> {} }:
pkgs.stdenv.mkDerivation {
pname = "hugo-theme-m10c";
version = "8295ee8";
src = pkgs.fetchFromGitHub {
owner = "sshine";
repo = "hugo-theme-m10c";
rev = "feat/hovering-heading-anchor-links";
sha256 = "BFwCbYj9K2k6UwYzmnX4sfZ9NjzOdfjUMdesOwdlUn8=";
};
installPhase = ''
mkdir -p $out
cp -r * $out/
'';
}
Common to all these files is that they work without flakes by defaulting their input to in-directory
or in-path references. The ? import <nixpkgs> {} part ruins reproducibility, but it’s a tradeoff:
You get reproducibility via flakes, you get speed without.
I think this gradual opt-in to flakes is very nice.
This is also a good reason to not inline everything into flake.nix.
Vendoring Hugo themes with git ¶
I’ll fork the original repository and make any changes there.
My fork will serve as a basis for my local deployment as well as propagating the change upstream.
let
hugo-theme = builtins.fetchFromGitHub {
owner = "sshine";
repo = "hugo-theme-m10c";
rev = "862c6e941be9bc46ce8adc6a2fa9e984ba647d6f";
sha256 = "12jvbikznzqjj9vjd1hiisb5lhw4hra6f0gkq1q84s0yq7axjgaw";
};
in
...
Nix will automatically fetch and deploy the second git repo.
But now developing on my blog is split between two repos.
For practical development purposes, I want to simply clone my fork:
git clone git@github.com:sshine/hugo-theme-m10c.git themes/m10c-dev
sed -i "s/^theme = .*/theme = 'm10c-dev'/" hugo.toml
Managing dual deployment modes ¶
When using nix build .# for production, the theme gets symlinked to themes/default from the Nix store. But for development, I want to use themes/m10c-dev as a git clone. The problem is that hugo.toml can only specify one theme.
I found four approaches that don’t require constantly editing hugo.toml, which I’ll list below.
I went with option 2 because it aligns with having a justfile, so I can have one just command for each way to deploy.
Option 1: Symlink switching ¶
Keep theme = 'default' in hugo.toml, but control what themes/default points to.
For development:
ln -snf m10c-dev themes/default
For production, Nix could handle this automatically in both shell.nix and default.nix:
ln -snf "${theme}" themes/default
This is simple and works with existing tooling.
The Nix build would always override the symlink during builds anyway.
It’s quite elegant, but I fear I might get confused what the symlink points to at any one moment.
Option 2: Command-line override (my preference) ¶
Hugo accepts a --theme flag that overrides the config file setting:
# Development
hugo server --theme m10c-dev
# Production (using git-cloned development theme)
hugo --theme m10c-dev
# Production (using the Nix-vendored theme; --theme could be omitted)
hugo --theme default
This way I don’t need to change the config file, I run different commands:
# Build the Hugo site using default.nix
build-nix:
nix build .# --out-link public-result
# Build the Hugo site using shell.nix
build-dev:
hugo --minify --theme m10c-dev
Option 3: Environment-based config files ¶
Hugo supports per-environment configuration files. Create config/dev/hugo.toml:
theme = 'm10c-dev'
Then use different commands for each environment:
# Development
hugo server --environment dev
# Production (using git-cloned development theme)
hugo --environment dev
# Production (using the Nix-vendored theme; --theme could be omitted)
hugo # uses default environment
The Nix build doesn’t specify an environment, so it uses the default configuration.
Since I only change a single option, I’d prefer to not have multiple config files.
Option 4: Smart shell.nix ¶
Modify the shellHook in shell.nix to automatically detect and use the local development theme:
shellHook = ''
mkdir -p themes
# Use m10c-dev if it exists, otherwise use vendored theme
if [ -d themes/m10c-dev ]; then
ln -snf m10c-dev themes/default
else
ln -snf "${theme}" themes/default
fi
'';
This makes the development shell automatically use your local clone when present, while production builds remain unchanged.