djuntgen@juntgen.com
← all posts

Building a Homelab with AI · part 25

Part 25: Eliminating the Last Snowflake

#homelab#docker#ansible#astro#gitops

After Part 23, where we replaced Portainer with the docker-stacks Ansible role, nearly every piece of the homelab was managed as code. Deployments were explicit. Configuration lived in git. The exception was the thing you’re reading right now.

The juntgen.com personal site — an Astro app running in a Docker container on docker-host — had no git history, no Ansible management, and no path to disaster recovery beyond “re-build it by hand.” It was a classic snowflake: a manual artifact that drifted from anything written down.

Worse: the site was running blog posts 00–16. The monorepo had posts 00–24. Eight posts existed in docs/blog/ and had never appeared on the actual website.

This was overdue.

The Problem With the Old Setup

The old site lived at /home/user/sites/juntgen.com on docker-host. There was a docker-compose.yml, a Dockerfile, and the Astro source code — but no .git directory. It had started as a git repo at some point, apparently been detached, and drifted. The blog posts were duplicated inside src/content/blog/ in that directory, entirely separate from the canonical copies in docs/blog/ in the homelab monorepo.

The docker_build_stacks Ansible role entry pointed at that directory:

docker_build_stacks:
  - name: juntgencom
    repo_dest: "/home/user/sites/juntgen.com"
    compose_file: "docker-compose.yml"

This ran docker compose up -d in that directory, which worked — but it meant docker-host had a directory of source code that Ansible didn’t manage, git didn’t track, and nobody would remember how to recreate.

The core issue: there were two sources of truth for blog content. docs/blog/ in the monorepo (the one we actually wrote into, where new posts appeared) and src/content/blog/ on docker-host (the one the site actually read from). These diverged and stayed diverged.

The Architecture Decision

The goal was to eliminate the duplication entirely: one copy of the blog posts, one place to push, one command to deploy.

docs/blog/ would be the single source of truth. The Dockerfile would copy those posts into the Astro build at build time. There would be no src/content/blog/ directory in the repo — it would be a build-time artifact, not a committed directory.

The site source code (src/pages/, src/components/, src/layouts/, etc.) would move into stacks/juntgencom/ in the monorepo, alongside the monitoring and openwebui stacks. The Dockerfile would reference both paths at build time using repo-relative COPY instructions.

The key change that made this work: context: ../.. in docker-compose.yml.

How the Docker Build Context Works

The original docker-compose.yml on docker-host was:

services:
  juntgen-com:
    build: .

build: . sets the Docker build context to the directory containing the compose file. With COPY . . in the Dockerfile, that copies the entire site into the container — including src/content/blog/, which lived right there alongside the source.

The new setup sets the build context to the repo root:

services:
  juntgen-com:
    build:
      context: ../..
      dockerfile: stacks/juntgencom/Dockerfile

The compose file lives at /opt/stacks/stacks/juntgencom/docker-compose.yml on docker-host. So ../.. resolves to /opt/stacks/ — the root of the homelab repo clone. With the build context at repo root, the Dockerfile can now reference any path in the repo:

FROM node:24-alpine AS builder
WORKDIR /app
COPY stacks/juntgencom/package*.json ./
RUN npm ci
COPY stacks/juntgencom/ ./
COPY docs/blog/ ./src/content/blog/
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY stacks/juntgencom/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

The critical line is COPY docs/blog/ ./src/content/blog/. The blog posts are injected from docs/blog/ into the Astro build — they don’t live in the site source at all. When we write a new blog post and push to the monorepo, the next ansible-playbook playbooks/docker-stacks.yml run will rebuild the container and the new post appears.

No sync step. No duplication. No divergence possible.

Keeping the Build Context Small

A wider build context means more data transferred to the Docker daemon on every build. The repo root includes Ansible roles, Terraform configs, and documentation — none of which the Astro build needs.

A .dockerignore at the repo root handles this:

*
!stacks/juntgencom/
!docs/blog/

The * excludes everything, then the ! lines whitelist only what the Dockerfile actually COPYs. The build context is just the site source and the blog posts — a few megabytes instead of the full repo.

Moving juntgencom to Image-Based Stacks

Update (2026-03-24): This section’s categorization was reversed in Part 26. juntgencom has a build: directive and a Dockerfile — by definition it’s a build-based stack. The --build flag added below was a workaround for the miscategorization. It now lives in docker_build_stacks where the build semantics are explicit. The monorepo move and Docker build context changes described in this post remain correct.

With the site source now in the monorepo, juntgencom no longer belongs in docker_build_stacks. It belongs alongside monitoring and openwebui in docker_image_stacks:

docker_image_stacks:
  - monitoring
  - openwebui
  - juntgencom

The difference: image-based stacks live in this repo under stacks/<name>/ and are deployed by git clone + docker compose up -d. Build-based stacks are separate repos that happen to be on docker-host. juntgencom is now clearly the former.

We also added --build to the image-based stack deploy command:

- name: Deploy image-based stacks
  ansible.builtin.command:
    cmd: >
      docker compose
      -f {{ docker_stacks_repo_dest }}/stacks/{{ item }}/docker-compose.yml
      -p {{ item }}
      up -d --build --remove-orphans

The --build flag is a no-op for stacks without a build: directive — monitoring and openwebui use image: only, so --build does nothing for them. For juntgencom, it triggers a full image rebuild on every deploy. That’s intentional: we always want the latest blog posts and site code, and the build is fast (under two minutes with npm’s layer cache).

The Gitignore Detail

One edge case: if someone clones the repo and tries to do local Astro development, they’ll notice src/content/blog/ doesn’t exist. The posts are in docs/blog/ and gitignored from the site source:

# Blog posts come from docs/blog/ at Docker build time
src/content/blog/

For local development, the plan is to symlink:

cd stacks/juntgencom
ln -s ../../docs/blog src/content/blog
npm install && npm run dev

This keeps local dev working without committing a copy of the posts into the wrong place.

The Result

The deploy:

TASK [docker-stacks : Deploy image-based stacks]
changed: [docker-host] => (item=monitoring)
changed: [docker-host] => (item=openwebui)
changed: [docker-host] => (item=juntgencom)

Verification:

ssh docker-host 'docker ps | grep juntgen'
# juntgen-com   Up 6 seconds   0.0.0.0:8100->80/tcp

ssh docker-host 'curl -s -o /dev/null -w "%{http_code}" http://localhost:8100'
# 200

ssh docker-host 'curl -s http://localhost:8100/blog/ | grep -oP "\d+ posts and counting"'
# 24 posts and counting

All 24 posts live — posts 17 through 24 appearing on the site for the first time.

The snowflake directory on docker-host:

ssh docker-host 'rm -rf /home/user/sites/juntgen.com'

Gone.

Lessons Learned

Content and code have different source-of-truth requirements. The blog posts belong to the project documentation — they’re in docs/blog/ alongside requirements.md, design.md, and roadmap.md. The site theme and structure belong to the stack. Keeping them separate and composing them at build time respects that boundary. The Dockerfile becomes the join point, not the storage location.

Docker build context is a design decision. The conventional build: . pattern works well when source and container live in the same directory. When they don’t — when content lives somewhere else in the repo — you need to think explicitly about what the context should be. context: ../.. with repo-relative COPY paths is clean once you understand it; it’s just not the first thing most Dockerfiles demonstrate.

Snowflakes accumulate quietly. The juntgencom site had been running for months in an unmanaged state and nothing had broken. The cost of the snowflake was invisible: posts not appearing on the site, a recovery path that didn’t exist, a directory on docker-host that no one would know how to recreate. None of that surfaces until you need it to. The right time to eliminate a snowflake is before that moment.

One deploy command. The homelab now has a single command to bring the full Docker layer to the desired state — stacks, images, builds, secrets. Every change goes through git. Every deploy is intentional and traceable. That’s what we were trying to build from the beginning.