Skip to main content
My homelab from scratch (Part 6): phase 0, laying the foundations before the first server
Photo by Pankaj Patel / Unsplash
  1. Articles/

My homelab from scratch (Part 6): phase 0, laying the foundations before the first server

·1933 words·10 mins·
Table of Contents
My homelab from scratch - This article is part of a series.
Part 6: This Article

In part 5, I laid out the whole architecture on paper: Proxmox in a cluster, everything in code, secrets with SOPS, Kubernetes with Talos, observability… A lot of theory, zero commands. I promised you the next one would be 100% practical. Here we are.

We’re kicking off phase 0 of the roadmap. And against all expectations, this first practical phase doesn’t touch the hardware. Not yet. Before plugging in a single server, I need to prepare the workstation and lay the “everything in code” foundation: the tooling, the root of trust that will encrypt all my secrets, the versioned repo skeleton, and the guardrails that stop me from committing something silly.

note

A reminder of the hardware context for this phase: everything happens on my workstation, a MacBook Pro (macOS with an Apple Silicon chip), and the repo lives on GitLab.com in a private project I’ll call kentrowlab throughout the series.

The goal of the phase fits in one sentence: put in place everything that has to exist before touching the hardware. By the end, the foundation is ready to receive the following phases. It plays out in six steps.

Step 1: install the toolchain
#

Everything goes through Homebrew, macOS’s package manager. In a handful of commands, I install all the IaC, secrets and code-quality tooling I’ll need from the very first phases:

# Core IaC + secrets + quality (needed from phases 0-3)
brew install opentofu ansible sops age pre-commit gitleaks ansible-lint git jq yq

# Packer (HashiCorp tap - BSL licence, see note below)
brew install hashicorp/tap/packer

# tflint (dedicated tap from the terraform-linters project)
brew tap terraform-linters/tap
brew install terraform-linters/tap/tflint

# Kubernetes CLIs (useful in phases 6-7, but set up now)
brew install kubectl helm argocd
brew install siderolabs/tap/talosctl

One small detail that matters: I install Packer via the HashiCorp tap rather than from the central repository. The reason is the same one that made me pick OpenTofu over Terraform back in part 5: Packer moved to the BSL licence in 2023.

To check everything is in place, a little validation block that lists each binary:

for t in tofu ansible sops age packer pre-commit gitleaks tflint ansible-lint \
         kubectl helm argocd talosctl; do
  printf "%-13s %s\n" "$t:" "$(command -v $t || echo 'ABSENT')"
done

Step 2: the age key, my root of trust
#

This is the most important step of the whole phase, and probably of the entire project. This key will decrypt everything SOPS protects from here on. Losing it without a copy means losing access to all of my encrypted content. Needless to say, it deserves the utmost care.

I generate the age key pair at the location SOPS reads by default on macOS:

mkdir -p "$HOME/Library/Application Support/sops/age"
chmod 700 "$HOME/Library/Application Support/sops/age"

age-keygen -o "$HOME/Library/Application Support/sops/age/keys.txt"
chmod 600 "$HOME/Library/Application Support/sops/age/keys.txt"

The command produces two things: a private key (AGE-SECRET-KEY-1...) that will never leave my workstation, and a public key (age1...) that can be shared without any risk and which will go into the repo in step 4.

Escrow, deliberately manual
#

A key this critical deserves a backup copy. I copy the entire keys.txt file into a Bitwarden secure note (my password manager). And I do it by hand, on purpose: the last thing I want is for this private key to pass through my shell history or a scriptable clipboard.

note

Bitwarden is not in the project’s automatic bootstrap path. It’s a manual recovery vault, not a pipeline dependency. The copy destined for CI/CD will go into a protected variable, but only in phase 3. Here, we’re just laying down the root of trust and its backup.

It’s the only secret I have to protect manually. Everything else flows from it.

Step 3: the repo skeleton
#

I create a private kentrowlab project on GitLab, without an init README (I push my own skeleton), and I clone it locally. Then I set up a layered tree, one folder per tool in the chain:

mkdir -p \
  answer-files \
  ansible/inventory ansible/group_vars ansible/roles ansible/playbooks \
  packer \
  tofu/modules tofu/stacks \
  argocd \
  docs

Each layer of the architecture has its folder: answer-files/ for the Proxmox install, ansible/ for host configuration, tofu/ for provisioning, packer/ for images, argocd/ for GitOps. It’s compartmentalised, so easy to evolve (or even split into several repos) later.

The .gitignore, the first security brick
#

A good chunk of the project’s hygiene plays out right here. The principle is simple but crucial: I version the encrypted files (*.sops.*), and I ignore absolutely everything that must never enter git, namely the private keys, the decrypted files, the OpenTofu state, the kubeconfigs and talosconfigs.

# --- Secrets & keys (NEVER in git) ---
*.agekey
keys.txt
*.dec
*.decrypted
# NB: the encrypted files *.sops.yaml / *.sops.json ARE versioned

# --- OpenTofu / Terraform ---
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
# We VERSION the lockfile: do not ignore .terraform.lock.hcl

# --- Kubernetes / Talos ---
kubeconfig
*.kubeconfig
talosconfig
*.talosconfig

The ghost-folder trap
#

A small detail that can cost you dearly: git doesn’t version empty folders. My brand-new tree would therefore vanish on the next clone, which would break the “everything rebuildable from git” principle stone dead. The classic workaround: drop an empty .gitkeep file in each folder. A little find check confirms no empty folder is lying around:

find . -path ./.git -prune -o -type d -empty -print   # should return nothing

Step 4: wire up SOPS and prove the chain
#

All that’s left is to connect SOPS to my age key. That’s done via a .sops.yaml file at the root, which declares that any secrets file will be encrypted for my public key:

# .sops.yaml
creation_rules:
  - path_regex: .*\.sops\.(ya?ml|json|env)$
    age: age1.........................................X

This file can be committed without a problem: it only contains the public key and some rules, nothing secret.

And now, the validation: proving the chain works end to end. I create a fake secret, encrypt it, decrypt it, and clean up:

cat > test.sops.yaml << 'EOF'
demo_secret: "hello-kentrowlab"
EOF

sops -e -i test.sops.yaml   # encrypt in place
cat test.sops.yaml          # -> ENC[...] values, readable structure
sops -d test.sops.yaml      # -> shows the plaintext again
rm test.sops.yaml           # tidy up

The result is exactly what I expected: the value becomes ENC[AES256_GCM,...], but the demo_secret key stays readable, and sops -d restores the plaintext correctly, all without me having to specify where my key is (SOPS finds it on its own in ~/Library).

This behaviour illustrates an essential point about SOPS: it encrypts the values, not the structure. Concretely, that means my git diffs stay usable: I’ll see which key changed, without ever exposing its value. That’s the whole point of being able to commit secrets without committing a secret.

Step 5: local guardrails (pre-commit)
#

Having the encryption machinery is good. Making sure I never forget to use it is better. So I install pre-commit with a .pre-commit-config.yaml file that stacks several hooks: file hygiene, gitleaks (secret detection), the terraform_fmt/tflint and ansible-lint linters, and above all a homemade hook that rejects any *.sops.* file that isn’t encrypted.

# Guardrails run before every commit. See https://pre-commit.com
minimum_pre_commit_version: "3.0.0"

repos:
  # Basic file hygiene
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-merge-conflict
      - id: check-added-large-files
        args: ["--maxkb=2048"]
      - id: check-yaml
        exclude: '\.sops\.ya?ml$'

  # Plaintext secret detection
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.30.1
    hooks:
      - id: gitleaks

  # Checks that no SOPS secrets file is committed IN PLAINTEXT
  - repo: local
    hooks:
      - id: forbid-unencrypted-sops
        name: "Reject unencrypted .sops files"
        entry: >-
          bash -c 'for f in "$@"; do
            if [ -f "$f" ] && ! grep -q "ENC\[" "$f"; then
              echo "ERROR: $f is not encrypted by SOPS"; exit 1;
            fi; done' --
        language: system
        # We match SECRETS files (*.sops.*) but NOT the .sops.yaml config
        files: '\.sops\.(ya?ml|json|env)$'
        exclude: '^\.sops\.yaml$'

  # Lint OpenTofu / Terraform (format + rules)
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.108.0
    hooks:
      - id: terraform_fmt
      - id: terraform_tflint

  # Lint Ansible
  - repo: https://github.com/ansible/ansible-lint
    rev: v26.6.0
    hooks:
      - id: ansible-lint

Once the file is in place, you install the git hook and run a first pass over the whole repo:

pre-commit install
pre-commit run --all-files
note

Two subtleties worth noting. First, terraform_fmt and tflint work perfectly on OpenTofu code: the .tf format is identical, the hooks just keep the “terraform” name for historical reasons. Second, pre-commit run --all-files only acts on files known to git. As long as nothing is git added, most hooks display “no files to check → Skipped”. They’ll really kick in at commit time.

Step 6: first commit, CI, and closing the phase
#

The last step: replay the same guardrails server-side, because a local hook can be bypassed (deliberately or not). A minimal .gitlab-ci.yml runs GitLab’s Secret Detection (also based on gitleaks) plus a homemade job that checks no *.sops.* file is in plaintext. Double barrier: local and server.

Then comes the big moment, the initial commit:

git add -A
git commit -m "chore: bootstrap phase 0 - repo skeleton, SOPS+age, guardrails"
git push -u origin main

The snag I didn’t see coming
#

And of course, it didn’t go through on the first try. My own anti-secret guardrail triggered… on its own configuration:

Reject unencrypted .sops files...................................Failed
ERROR: .sops.yaml is not encrypted by SOPS

The culprit: my \.sops\.yaml$ pattern also matched the .sops.yaml file itself. Except that this file is the configuration of SOPS (public key + rules), it’s public by nature and it absolutely must not be encrypted, otherwise SOPS can’t read its own rules any more. The fix: explicitly exclude .sops.yaml from the hook (exclude: '^\.sops\.yaml$') and apply the same exception on the CI side.

It’s a false positive you only catch at runtime, and that’s exactly why I find it valuable to test your guardrails while they’re empty, before you have real secrets to protect. The day a real secret is on the line, the safety net needs to already be reliable.

note

Another little trap in the first commit: the end-of-file-fixer and trailing-whitespace hooks can fix a file on the fly and make the commit fail. That’s intended. You re-stage (git add -A) and re-commit, and this time it goes through.

After this little round of adjustments, the GitLab pipeline goes green. Phase 0 is done.

Where things stand after phase 0
#

If I take stock, here’s what’s in place:

Complete toolchain on the workstation: OpenTofu, Ansible, Packer, SOPS, age, the linters and the Kubernetes CLIs

Root of trust: age key generated and backed up offline in Bitwarden

Repo structured by layers, ready to host each tool in the chain

SOPS chain wired up and proven with a full round-trip

Double-barrier guardrails: pre-commit locally, CI server-side

At this stage, the versioned repo only contains the skeleton: .gitignore, .gitlab-ci.yml, .pre-commit-config.yaml, .sops.yaml, a README.md, and all the .gitkeeps. No keys.txt, no decrypted file, no state. Exactly what we want.

It might seem like a lot of ceremony for having “deployed nothing”. But it’s quite the opposite: this phase 0 is the foundation that makes every following phase replayable and safe. The day I commit my first real secret, the whole safety net is already in place and tested.

And now?
#

The workstation is ready, the root of trust is laid down, the repo is waiting for its first real building blocks. In part 7, we finally move on to hardware: the physical installation of the four nodes, with the Proxmox answer files and the JetKVM to drive it all remotely. We start, for real this time, turning the plan into machines that actually run.

See you soon!

Kentrow
Author
Kentrow
Sharing IT tips and notes: networking, servers, DevOps, security, homelab and more.
My homelab from scratch - This article is part of a series.
Part 6: This Article

Related