Step-by-step instructions to bring the homelab up from scratch. After Phase 1 all work is done by Ansible — manual steps are kept to the absolute minimum.
Estimated time: 2–3 hours for a full deployment (Phases 1–4).
Quick links: 📋 Prerequisites · 0️⃣ Phase 0: Flash the SD Card · 1️⃣ Phase 1: PC → Edge (Bootstrap) · 2️⃣ Phase 2: Edge → Self-Deploy · 3️⃣ Phase 3: Edge → Other Nodes · 4️⃣ Phase 4: Automated Deployments
Full outline
- Bootstrap Guide
- 📋 Prerequisites
- 0️⃣ Phase 0: Flash the SD Card
- 1️⃣ Phase 1: PC → Edge (Bootstrap)
- 1.1 Prepare Your PC
- 1.2 Move the Repo into the WSL Filesystem
- 1.3 Find the Edge Node IP
- 1.4 Generate SSH Keys
- 1.5 Add GitHub Deploy Key
- 1.6 Copy SSH Key to Edge Node
- 1.7 Load SSH Key into Agent
- 1.8 Set Up Tailscale OAuth Client
- 1.9 Create Ansible Vault and Override Config
- 1.10 Run the Bootstrap Playbook
- What the Bootstrap Playbook Does
- 2️⃣ Phase 2: Edge → Self-Deploy
- 3️⃣ Phase 3: Edge → Other Nodes
- 4️⃣ Phase 4: Automated Deployments
- All Raspberry Pis flashed with Raspberry Pi OS Lite (64-bit) — see Phase 0
- Edge node powered on and connected to WiFi (or Ethernet)
- Static DHCP reservations configured on router (recommended — can be done after initial boot)
- GitHub account with access to this repository
- Cloudflare account (for Cloudflare Tunnel)
- Tailscale account
- This repository cloned on your PC
- Ansible Vault password chosen and stored securely (password manager recommended)
Goal: Write Raspberry Pi OS to the SD card and pre-configure the OS so the Pi boots ready for SSH — no keyboard or monitor needed.
Repeat for each node. The steps below use
homelab-edgeas the example; swap the hostname for other nodes.
Download Raspberry Pi Imager (v1.8+) from raspberrypi.com/software and install it on your PC or Mac.
- Open RPi Imager
- Choose Device → select your Raspberry Pi model (e.g. Raspberry Pi 4)
- Choose OS → Raspberry Pi OS (other) → Raspberry Pi OS Lite (64-bit)
- Choose Storage → select your SD card (double-check the size — this will be erased)
- Click Next
When prompted "Would you like to apply OS customisation settings?" click Edit Settings.
General tab:
| Field | Value |
|---|---|
| Hostname | homelab-edge |
| Username | admin |
| Password | A strong temporary password (you'll use this once in step 1.6) |
| Configure wireless LAN | ✅ Enabled |
| SSID | Your WiFi network name |
| Password | Your WiFi password |
| Wireless LAN country | Your country code (e.g. US, GB) |
| Set locale | Your timezone and keyboard layout |
No static IP here. The Pi will obtain a DHCP address over WiFi at first boot. You'll discover the address in step 1.3 and configure a static reservation (or Ansible-managed static IP) later.
Services tab:
| Field | Value |
|---|---|
| Enable SSH | ✅ Enabled |
| Authentication | Use password authentication |
Password auth is only needed for the initial
ssh-copy-idin step 1.6. Ansible will disable it and enforce key-only auth during the bootstrap playbook.
Click Save, then Yes to apply the settings.
- Confirm the write prompt — RPi Imager will erase and flash the card
- Eject the SD card safely once writing and verification complete
- Insert the SD card into the Pi and apply power
- Allow 60–90 seconds for first boot (the Pi expands the filesystem on first start)
- Confirm the Pi has joined the network:
If mDNS (
ping homelab-edge.local
homelab-edge.local) doesn't resolve, check your router's DHCP lease table for the IP.
Ethernet alternative: If you plug in Ethernet instead of (or as well as) WiFi, the Pi will prefer Ethernet. Either works for bootstrap — WiFi is expected at this stage and the IP will change once static DHCP is configured.
Goal: Convert homelab-edge from a fresh OS install into the Ansible control node for the entire homelab.
This is the only phase that requires manual work from your PC. Everything from Phase 2 onwards runs on the edge node.
Windows (WSL recommended):
wsl --install -d Ubuntu-22.04Inside Ubuntu, install the required tools:
sudo apt update
sudo apt install -y ansible python3-pip sshpass gitVerify:
ansible --version # Expect 2.14+
python3 --version # Expect 3.10+macOS / Linux: Install Ansible via your package manager or pip install ansible.
Windows only. Windows-mounted paths (including OneDrive) do not support the file permissions required by SSH and Ansible Vault.
cp -r /mnt/c/Users/<your-user>/path/to/homelab ~/homelab
cd ~/homelabVerify you are in the WSL filesystem:
pwd
# Expected: /home/<wsl-user>/homelabping homelab-edge.localIf mDNS is not available, check your router's DHCP lease table or use a network scanner. You'll need this IP in step 1.9 (ip_edge in overrides.yml).
From the repo root:
mkdir -p .ssh
ssh-keygen -t ed25519 -f .ssh/homelab-edge -C "homelab-edge"
ssh-keygen -t ed25519 -f .ssh/homelab -C "homelab"
ssh-keygen -t ed25519 -f .ssh/deploy -C "deploy"
ssh-keygen -t ed25519 -f .ssh/homelab-github -C "homelab-repo"Passphrase guidance:
| Key | Passphrase | Reason |
|---|---|---|
homelab-edge |
Yes | Used for interactive admin login |
homelab |
No | Used by Ansible automation (non-interactive) |
deploy |
No | Used by webhook/SSH trigger (non-interactive) |
homelab-github |
No | Used by edge to clone the repo |
Set correct permissions:
chmod 600 .ssh/homelab-edge .ssh/homelab .ssh/deploy .ssh/homelab-github
chmod 644 .ssh/*.pubVerify:
ls -la .ssh/
# Private keys: -rw-------
# Public keys: -rw-r--r--Display the key:
cat .ssh/homelab-github.pubAdd it to GitHub:
- Go to Repository → Settings → Deploy Keys → Add deploy key
- Name:
homelab-edge - Paste the public key
- Access: Read-only
- Save
This allows the edge node to clone and pull the repo without a personal access token.
ssh-copy-id -i .ssh/homelab-edge.pub admin@<ip_edge>Enter the admin password when prompted. This is the only time a password is used for SSH.
Load the key into ssh-agent once so Ansible doesn't prompt for the passphrase on every task:
eval "$(ssh-agent -s)"
ssh-add .ssh/homelab-edgeVerify the connection:
ssh admin@<ip_edge>exitIf the connection succeeds without a password prompt, the key is installed correctly. The agent stays active for the rest of your terminal session.
Tailscale needs three things configured in the admin console before you populate the vault — you'll need the OAuth credentials in the next step.
Step 1 — Create the device tag (login.tailscale.com/admin/acls/visual/tags):
- Click Add tag
- Name it
homelab(Tailscale prefixes it automatically astag:homelab) - Set owner to yourself or
autogroup:admin - Save
Step 2 — Create an access rule (login.tailscale.com/admin/acls/visual/general-access-rules):
- Click Add rule
- Source:
autogroup:adminandtag:homelab - Destination:
tag:homelab(nodes in the homelab tag can reach each other) - Save
Step 3 — Create OAuth credentials (login.tailscale.com/admin/settings/trust-credentials):
- Click Add credentials
- Under Scopes, enable Auth Keys (write) and, under the Devices category, Read
(API scope
devices:core, read) — the latter letsroles/tailscalecheck the tailnet's existing device list before minting a new identity, so a node whose local Tailscale state was lost (re-flash, wipedtailscaled.state) fails loudly instead of silently registering as a duplicate (seedocs/TROUBLESHOOTING.md§ Tailscale).If you're editing an OAuth client created before this note existed, check whether the admin console lets you add a scope to it in place. If not, create a new client with both scopes instead and update
vault_tailscale_oauth_client_id/vault_tailscale_oauth_client_secretinvault.yml— a device-list 403 ("missing devices:core read access") means whichever client is configured is missing this scope. - Under Tags, select
tag:homelab - Click Create and copy the Client ID and Client secret immediately — the secret is only shown once
Tag scopes cannot be changed after creation. If you forget to select
tag:homelab, delete the credential and create a new one.
You will need the Client ID and Client secret in §1.9.
Step 4 — Enable HTTPS certificates (login.tailscale.com/admin/dns):
- Scroll to HTTPS Certificates
- Click Enable HTTPS
This allows nodes to run tailscale cert to obtain a browser-trusted Let's Encrypt certificate for their MagicDNS hostname (<node>.<tailnet>.ts.net). The edge node uses this to serve Infisical and Semaphore over HTTPS on ports 8443/8444 with a real green-padlock certificate instead of a self-signed one.
You will also need your tailnet name (the short name shown on this same DNS page, e.g.
mango-beaver) foroverrides.ymlin §1.9.
Create a vault password file:
printf "your-strong-vault-password" > .vault_pass
chmod 600 .vault_passPoint Ansible at it via an environment variable (add to ~/.bashrc / ~/.zshrc so it persists across sessions):
echo 'export ANSIBLE_VAULT_PASSWORD_FILE=~/homelab/.vault_pass' >> ~/.bashrc
source ~/.bashrcCreate the vault:
EDITOR=nano ansible-vault create inventories/group_vars/all/vault.ymlPopulate it using inventories/group_vars/all/vault.yml.example as a reference. Save and exit (:wq in Vim).
vault_infisical_admin_email/vault_infisical_admin_password— set these to REAL values now, not"changeme". Unlike everything else in this block, the bootstrap playbook's one-shotPOST /v1/admin/bootstrapcall (see What the Bootstrap Playbook Does) uses these to create Infisical's actual admin account — they become real, permanent login credentials, and the playbook's preflight will refuse to run if they're still placeholders. Use a strong, unique password; this account has full control over every secret in the homelab.vault_infisical_encryption_key— generate withopenssl rand -hex 16. This one is also REAL-value-required and PERMANENT (a changed key can't decrypt the existing Postgres volume).vault_semaphore_admin_*— yours to set directly; Semaphore comes up at the end of the same bootstrap run.Notice what's not in this list: there are no
vault_infisical_bootstrap_client_*/vault_infisical_runtime_client_*placeholders to fill in, before the run or after. The bootstrap playbook provisions Infisical's one machine identity (read-onlyruntime) via its REST API and writes its credentials straight to a node-local file the instant they're minted — nothing is ever printed for you to copy into this vault. See What the Bootstrap Playbook Does and Secrets.
If you created vault.yml by copying
vault.yml.exampledirectly (instead of usingansible-vault create), encrypt it now:ansible-vault encrypt inventories/group_vars/all/vault.yml
Verify the vault is encrypted before continuing — the file should start with $ANSIBLE_VAULT;:
head -1 inventories/group_vars/all/vault.ymlCreate the local config:
cp inventories/group_vars/all/overrides.yml.example \
inventories/group_vars/all/overrides.ymlEdit overrides.yml and fill in your actual IPs, lan_subnet, and tailscale_tailnet (the short tailnet name from the Tailscale DNS page — step 4 of §1.8). These override the EDIT_BEFORE_USE placeholders in main.yml and are automatically copied to the edge node by the bootstrap playbook.
One playbook, one pass, zero manual steps in between. It brings Infisical up
and fully provisions and seeds it via its REST API (org, admin account,
homelab project, production environment, the nine application folders, the
read-only runtime machine identity, and every application secret from
vault.yml), configures the firewall, and brings Semaphore online — reading
its own Infisical runtime credentials straight from the node-local file the
provisioning step just wrote. Nothing is printed for you to transcribe
anywhere. See What the Bootstrap Playbook
Does for the full breakdown.
Make sure the SSH key is loaded in ssh-agent (step 1.7) before running.
ansible-playbook -i inventories/bootstrap.ini playbooks/bootstrap_edge.ymlNo password prompts — the SSH key passphrase is handled by ssh-agent and the admin sudo password is read from the vault (vault_admin_become_password).
Pre-flight checks run before anything touches the node:
- Verifies all required local files exist:
.ssh/homelab-github,.ssh/homelab,.vault_pass,vault.yml,overrides.yml - Asserts all required variables are defined (
ssh_port,github_org,homelab_repo_path, etc.)
Post-firewall validation:
After UFW is enabled, the playbook probes ssh_port from your PC (via wait_for) and fails immediately if SSH is unreachable — so a misconfigured firewall is caught before the play reports success.
Expected duration: 12–18 minutes (the Infisical bring-up — pulling images, provisioning via its REST API, seeding every application secret, and waiting for its API port — adds several minutes over a pre-Infisical bootstrap; most of that time is image pulls and container start/health-wait).
The playbook ends with a debug message confirming Infisical is up, fully provisioned, fully seeded, and that Semaphore is online too — see What the Bootstrap Playbook Does for the full breakdown of what just happened. Nothing is printed for you to copy anywhere; Phase 1 is simply done. Continue straight to Phase 2.
One playbook, one pass — bootstrap_edge.yml. Infisical's org, admin account,
project, environment, folders, its one machine identity, and every application
secret are all provisioned and seeded automatically via its REST API (see
"Provision and seed Infisical" below) — there is no manual setup step, no
credential hand-off, and (unlike an earlier design) no second playbook to run
afterward.
| Task | Detail |
|---|---|
| Set hostname | homelab-edge |
Create homelab user |
Ansible automation, passwordless sudo |
Create deploy user |
Webhook/SSH trigger, restricted sudo |
| Install Docker | |
| Install Tailscale | Brought up here (not Phase 2) — tailscale_up: true — so Infisical and Semaphore are reachable over the tailnet by the end of Phase 1. See the Mode note in NETWORK.md. |
| Install Ansible | Edge becomes a control node |
| Install Git | |
| Clone repo | /opt/homelab, owned by homelab |
| Copy files to node | overrides.yml, homelab SSH key pair, and vault_admin_become_password written straight to /home/homelab/.node_bootstrap_admin_pass (0600) for Phase 3's fresh-node probe — vault.yml/.vault_pass themselves are deliberately NOT copied (they live only on the WSL/PC control host; see Secrets). Phase 2+ resolves application secrets from Infisical at runtime instead (roles/infisical/tasks/lookup.yml) |
| Register SSH host key | Edge's own key added to /home/homelab/.ssh/known_hosts — required for Phase 2 self-deploy |
| Harden SSH | Key-only auth, no root login; port changed to ssh_port via async restart only if sshd is not already listening there (probe-first, idempotent on re-runs); subsequent tasks reconnect on new port automatically |
| Bring up Infisical | Renders /opt/infisical/.env (node-generated secrets), starts infisical-db/infisical-redis/infisical, waits for the API port (8222) to accept connections |
| Provision and seed Infisical | Drives Infisical's REST API (roles/infisical/tasks/bootstrap_instance.yml) — in one pass, over loopback, before the firewall locks the port down — to create the org, admin account, homelab project, prod environment, application folders, and the read-only runtime machine identity; push every [seed → ...] application secret from the WSL-local vault.yml into its mapped /prod/<folder>/<KEY> path (additive — existing keys untouched); and write the runtime identity's freshly-minted credentials straight to /home/homelab/.infisical_runtime_auth.yml. All of it authenticated with the one-shot bootstrap call's own instance-admin token — no separate write-capable identity is ever created, persisted, printed, or revoked. See that task's header comment for the full rationale. |
| Configure firewall | UFW default-deny inbound; allow ssh_port/tcp, 53/udp+tcp (Pi-hole DNS, LAN only), 8222/tcp + 3010/tcp (Infisical/Semaphore direct, Tailscale CGNAT range only); SSH reachability verified before play completes. Ports 80/8443/8444 (Caddy) opened in Phase 2 via apply_firewall.yml. |
| Bring up Semaphore | Renders /opt/semaphore/.env — node-generated Postgres password, vault_semaphore_admin_*, and the runtime identity's credentials loaded straight from /home/homelab/.infisical_runtime_auth.yml (never vault.yml, which never holds them) — then starts semaphore-db/semaphore |
| Enable unattended upgrades |
Sudo rules created:
# /etc/sudoers.d/homelab
homelab ALL=(ALL) NOPASSWD:ALL
# /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /opt/homelab/scripts/deploy.sh
How Infisical goes from a bare container to fully seeded with zero clicks:
roles/infisical/tasks/bootstrap_instance.yml calls Infisical's one-shot
POST /v1/admin/bootstrap endpoint — unauthenticated, returns an instance-admin
token on a fresh instance and refuses to run again once initialized. That token
drives the entire provision-and-seed pass (org, project, environment, folders,
runtime identity, all application secrets from vault.yml) before the firewall
locks port 8222 down. See CLAUDE.md §"Secrets" for the
full design rationale, including why no write-capable identity is ever created.
| Identity | Access level | Used by |
|---|---|---|
runtime |
Read-only access to the homelab project (all folders, production env) |
deploy_edge.yml and Semaphore — both read it from /home/homelab/.infisical_runtime_auth.yml, never vault.yml |
The playbook ends with Infisical running, fully provisioned, fully seeded, and
Semaphore online — all reachable over Tailscale (the firewall restricts ports
8222/3010/8443/8444 to the Tailscale CGNAT range). Confirm with tailscale status
(should list homelab-edge). Direct access to both is available immediately —
see Phase 2 → Direct access for the URL table
(Tailscale HTTPS on 8443/8444 is added once Phase 2 deploys Caddy); see also
docs/NETWORK.md.
Now run Phase 2 — deploy_edge.yml resolves its application secrets
(cloudflare/TUNNEL_TOKEN, pihole/WEB_PASSWORD) from Infisical via the
runtime identity that's already in place, so this is the first point it can
succeed. See Phase 2 for both ways to run it
(SSH into the edge, or directly from your PC/WSL):
ansible-playbook playbooks/deploy_edge.yml --limit homelab-edge
⚠️ This automation needs live verification against the deployed Infisical version —bootstrap_instance.ymlcarries an "API has moved across releases" caveat for every endpoint path/payload/response-field it touches (bootstrap, project/environment/folder/identity creation, Universal Auth, and the v3 raw-secrets routes for the seed). If the playbook fails partway through provisioning, see docs/TROUBLESHOOTING.md for how to wipe Infisical's volumes and retry against a clean instance.
Re-seeding is safe and idempotent, and re-running the whole playbook is too. The seed only creates keys that don't already exist (
status == 404); anything already in Infisical is left untouched, and the one-shot bootstrap call's own non-200 response on a re-run skips the entire provision-and-seed block (Infisical's already initialised — nothing to do). Adding a brand-new service later just means adding itsvault_<service>_<field>entries tovault.yml, its mapping to_infisical_seed_mapinroles/infisical/tasks/bootstrap_instance.yml, its folder toinfisical_seed_folders(roles/infisical/defaults/main.yml) and Infisical itself — then re-seeding via the Infisical web UI directly (Tailscale-reachable, trivial for occasional one-offs) rather than re-running a playbook whose provisioning half is now permanently a no-op.
Goal: The edge node deploys its own services using Ansible running locally.
SSH into the edge node:
ssh -p <ssh_port> -i .ssh/homelab-edge admin@<ip_edge>sudo /opt/homelab/scripts/deploy.sh
scripts/deploy.shgit-pulls the repo first (so the playbook is always current before Ansible loads it), then runsansible-playbook playbooks/deploy_edge.yml --limit homelab-edgeas thehomelabuser. No need to switch users orcdinto the repo — the script handles it. Pass extra args after the defaults if needed (seescripts/deploy.sh --help).
ansible.cfgsets the default inventory (prod.yml) — no-iflag needed. Re-run either option anytime (e.g. after pulling new changes, or recovering from a partial failure) —deploy_edge.ymlis idempotent.
What deploy_edge.yml does:
- Pulls latest repo from GitHub
- Deploys fail2ban (SSH and Pi-hole jails)
- Re-asserts Tailscale in subnet-router mode (already brought up and joined during Phase 1 — see What the Bootstrap Playbook Does; idempotent here, a no-op once joined)
- Installs and configures Unbound as a host systemd service (port 5335, DNSSEC-validating recursive resolver)
- Ships logs via Grafana Alloy (→ Loki once Phase 3 is up)
- Runs
deploy-service deploy homelab-edge-services— fetchesTUNNEL_TOKEN,PIHOLE_WEB_PASSWORD, and node IPs from Infisical and starts the network appliance stack:cloudflared(Cloudflare Tunnel — external ingress)- Caddy (LAN reverse proxy for
*.homelab.local) - Pi-hole (DNS, port 53 — upstream is Unbound on the host via
host.docker.internal#5335) pihole-exporter(metrics)node-exporter(host metrics, port 9100)- Portainer Agent
Firewall note: UFW rules are applied by
bootstrap_edge.yml(Phase 1) and persist. To update rules after adding new services run:ansible-playbook playbooks/apply_firewall.yml --limit homelab-edge
Direct access (no DNS required):
| Service | URL | Notes |
|---|---|---|
| Pi-hole admin | http://<ip_edge>:8080/admin |
Password: auto-generated at deploy time, see Infisical pihole/WEB_PASSWORD |
| Infisical | https://homelab-edge.<tailnet>.ts.net:8443 |
Tailscale HTTPS (browser-trusted); or http://<edge-tailscale-ip>:8222 (non-browser) |
| Semaphore | https://homelab-edge.<tailnet>.ts.net:8444 |
Tailscale HTTPS (browser-trusted); or http://<edge-tailscale-ip>:3010 (non-browser) |
| Portainer Agent | http://<ip_edge>:9001 |
Portainer Server connects here in Phase 3 |
| node-exporter metrics | http://<ip_edge>:9100/metrics |
Scraped by Prometheus in Phase 3 |
| pihole-exporter metrics | http://<ip_edge>:9617/metrics |
Scraped by Prometheus in Phase 3 |
Goal: Deploy the observe node and service nodes from the edge.
All commands in this phase run from homelab-edge itself — SSH in first:
ssh -p <ssh_port> -i .ssh/homelab-edge admin@<ip_edge>sudo su - homelabcd /opt/homelabPrerequisites: homelab-observe has the base OS installed and is reachable via SSH from the edge node.
# Bootstrap the observe node
ansible-playbook playbooks/bootstrap_node.yml --limit homelab-observe
# Deploy the monitoring stack
/opt/deploy-service-venv/bin/deploy-service deploy homelab-observe-services --config /opt/homelab/services.ymlWhat gets deployed:
- Tailscale (ACL: accessible from edge and admin devices only)
- Docker Compose stack:
- Prometheus (scrapes all
node-exporterinstances) - Loki (receives logs from all Grafana Alloy agents)
- Grafana (pre-configured dashboards and data sources)
- Alertmanager (routes alerts by severity)
- Uptime Kuma (HTTP endpoint monitoring)
- Portainer + Agent (Docker GUI for all nodes)
- Prometheus (scrapes all
- Edge's Alloy agent begins forwarding logs to Loki
- Prometheus begins scraping edge's
node-exporter
Nothing manual is needed after the deploy-service command finishes — its pre_hook/post_hook
chain (homelab-observe-services/services.yml entry) handles the rest: predeploy.sh expands
${IP_*}/${DISCORD_WEBHOOK_*} placeholders in the mounted config files before docker compose up, then setup_monitors.sh reconciles Uptime Kuma's monitors against monitors.yml and
setup_portainer.sh bootstraps the Portainer admin account via its REST API — see
homelab-observe-services/docs/MONITORING.md for both. You don't need to configure Uptime Kuma
monitors or create a Portainer admin account by hand.
# Bootstrap svc-01
ansible-playbook playbooks/bootstrap_node.yml --limit homelab-svc-01
# Deploy Camunda + n8n + authentik
deploy-service deploy camunda-platform --config /opt/homelab/services.yml
deploy-service deploy n8n-automation --config /opt/homelab/services.yml
deploy-service deploy authentik-sso --config /opt/homelab/services.ymlFor svc-02 (when provisioned):
ansible-playbook playbooks/bootstrap_node.yml --limit homelab-svc-02
ansible-playbook playbooks/deploy_svc.yml --tags greentechhubGoal: Deployments trigger automatically on push to master, without a self-hosted runner on the edge.
git push → master
↓
.github/workflows/deploy.yml (runs on GitHub's hosted runners)
↓
HTTP POST → n8n or Camunda webhook endpoint (running on homelab-svc-01)
↓
n8n/Camunda workflow SSHes to homelab-edge as `deploy` user
↓
deploy user runs: git pull + ansible-playbook directly
↓
Ansible deploys to all target nodes
GitHub only needs to reach the n8n or Camunda endpoint — it never connects directly to the edge or any other node. All
Ansible execution stays inside the homelab. The deploy user's restricted sudo allows only scripts/deploy.sh — the
script git-pulls the repo and then runs ansible-playbook as homelab.
Option A — n8n (recommended for simplicity):
- Create a new workflow triggered by Webhook node
- Validate the incoming request (check
X-Deploy-Secretheader matchesvault_deploy_webhook_secret) - Add an SSH node pointing to
homelab-edge, port{{ ssh_port }}, userdeploy, using thedeployprivate key - Commands:
sudo /opt/homelab/scripts/deploy.sh # edge (default) sudo /opt/homelab/scripts/deploy.sh deploy_svc homelab-svc-01The script git-pulls the repo before running
ansible-playbook, so the play is always current on every trigger.
Option B — Camunda:
- Deploy a BPMN process with a message start event
- Expose a REST endpoint via Camunda's API that receives the GitHub POST
- Use a service task to SSH to the edge and run the same
ansible-playbookcommands above
Either way, store the deploy private key as a credential inside n8n/Camunda — it never touches GitHub.
.github/workflows/deploy.yml runs on GitHub's own hosted runners (no self-hosted runner needed):
name: Deploy
on:
push:
branches: [master]
jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: Trigger deploy endpoint
run: |
curl -X POST \
-H "Content-Type: application/json" \
-H "X-Deploy-Secret: ${{ secrets.DEPLOY_SECRET }}" \
-d '{"ref": "${{ github.ref }}"}' \
${{ secrets.DEPLOY_ENDPOINT_URL }}Add two secrets in Repository → Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
DEPLOY_ENDPOINT_URL |
URL of your n8n webhook or Camunda REST endpoint |
DEPLOY_SECRET |
Shared secret; validate this in n8n/Camunda |
No Ansible Vault password, no SSH keys, and no homelab IPs are stored in GitHub.
For ad-hoc deployments without going through GitHub or n8n, SSH to the edge as deploy and run playbooks directly:
ssh -p <ssh_port> -i .ssh/deploy deploy@homelab-edge
# Run whichever playbooks are needed
sudo /opt/homelab/scripts/deploy.sh # edge (default)
sudo /opt/homelab/scripts/deploy.sh deploy_svc homelab-svc-01The deploy user's sudo is restricted to /opt/homelab/scripts/deploy.sh only — no shell, no root access. The script git-pulls the repo before running ansible-playbook, so the play is always current. Deployments work even if GitHub or the automation endpoint is unavailable.