This repo codifies the build, configuration, and ongoing management of my home NAS using Ansible and GitHub Actions. The goal is full Infrastructure as Code (IaC) — the NAS can be provisioned from scratch, updated, and restored entirely from this repository.
The main objectives of the project are:
- ✅ Codify the build and configuration of the base NAS
- ✅ Install the latest packages and updates
- ✅ Ensure required user accounts are present
- ✅ Ensure required folder structure is present
- ✅ Configure roles
- ✅ Docker - to allow deployment of docker containers on the NAS
- ✅ Storage - to configure / mount storage correctly for the NAS
- ✅ Samba - to allow network based access to NAS storage
- ✅ Backup - to ensure backup jobs are configured to protect data
- ✅ UPS Monitoring - to ensure NAS shuts down when UPS is running low
- ✅ Coral TPU - ensure Coral TPU is driver is installed
- Restore NAS state from latest backup (if required)
- Restore persistent docker data from network backup
- Initiate re-deployment of Docker Stacks from NAS-Docker
- ✅ Codify on-going management tasks for the NAS
- ✅ Automatic updates and reboots (if required)
I have created the following structure to manage this project. I have developed the structure to make to solution as modular as possible.
nas-as-code/
├── .github/
│ ├── dependabot.yml # Automated dependency updates for pinned Actions
│ └── workflows/
│ ├── validation.yml # CI — lint & syntax checks on push (non-main)
│ ├── deploy.yml # CD — provision via Ansible on PR merge to main
│ └── updates.yml # Scheduled — weekly OS updates (Tue 2AM UTC)
├── ansible/
│ ├── ansible.cfg # Ansible configuration (inventory path, roles path)
│ ├── inventory/
│ │ ├── environments.yml # Host definitions for test & production
│ │ └── group_vars/
│ │ ├── test.yml # Variables for the test environment
│ │ └── production.yml # Variables for the production environment
│ └── playbooks/
│ ├── provision.yml # Full NAS provisioning playbook
│ ├── updates.yml # OS package updates & reboot if required
│ └── roles/
│ ├── owendemooy.docker/ # Installs Docker CE & Compose
│ ├── owendemooy.storage/ # Configures MergerFS storage pool
│ ├── owendemooy.samba/ # Sets up Samba file shares
│ ├── owendemooy.nut-client/ # NUT UPS monitoring client
│ ├── owendemooy.nas-backup/ # Nightly rsync backup with WOL
│ └── owendemooy.coraltpu/ # Google Coral TPU driver & runtime
└── .gitignore
The inventory files are used to define two environments, each with their own matching GitHub Environment for secrets / variables:
| Environment | Host | Purpose |
|---|---|---|
test |
nas-build-test |
Test VM to validate all changes |
production |
nas |
The production NAS |
Environment-specific configuration lives in inventory/group_vars/{environment}.yml and includes:
functions— which roles to applyapt_packages— additional packages to installusers— user accounts and SSH keyshost_directories— required directories with ACLssamba_users/samba_shares— Samba configuration
The heavy lifting for the actual provisioning and updating is done using Ansible Playbooks. I have create two main Playbooks for this project to handle the provison and updates of the NAS.
The provision.yml playbook performs a full host setup:
-
Packages — Upgrades all packages and installs additional required packages
-
Host entries — Ensures
/etc/hostscontains all managed hosts -
Users — Creates required user accounts and configures SSH authorized keys
-
Directories — Creates required directory structure with correct ACLs
-
Roles — Conditionally applies roles based on the
functionsvariable:Role Function Purpose owendemooy.dockerdockerDocker CE & Compose installation owendemooy.storagestorageMergerFS disk pool configuration owendemooy.sambasambaSMB file sharing owendemooy.nut-clientnut-clientUPS monitoring via NUT owendemooy.nas-backupnas-backupNightly rsync backup with wake-on-LAN owendemooy.coraltpucoraltpuGoogle Coral Edge TPU drivers
The updates.yml playbook handles routine OS maintenance:
- Package upgrade — Installs the latest available versions of all installed packages via
apt upgrade - Reboot check — Inspects
/var/run/reboot-requiredto determine if a restart is needed (e.g. kernel update) - Safe reboot — If required, reboots the host and waits for it to come back online (up to 5 minutes)
I am using GitHub Actions to control the validation, provisioning and updates to my NAS. The GitHub actions have been designed to validate and test all changes again the test environment, before applying any changes to Production.
File: .github/workflows/validation.yml
Trigger: Any push to a non-main branch, or manual dispatch.
| Step | Description |
|---|---|
| Install dependencies | Installs ansible-dev-tools and the ansible.posix collection |
| Syntax check | Runs ansible-playbook --syntax-check on all playbooks |
| Ansible Lint | Lints playbooks and roles using Code Climate format for reporting |
| Test Report | Generates a markdown summary with findings table viewable on the Actions run page |
| SARIF Upload | Publishes lint findings to GitHub Code Scanning (inline PR annotations) |
| Fail gate | Fails the workflow if any lint violations are found |
| Microsoft Security DevOps | Scans with Checkov and Trivy for security misconfigurations and vulnerabilities |
| Upload Security SARIF | Publishes security findings to GitHub Code Scanning |
File: .github/workflows/provision.yml
Trigger: Pull request merged to main (ignoring markdown-only changes).
Deploys sequentially to test then production using a matrix strategy (fail-fast: true, max-parallel: 1). If test fails, production is skipped.
| Step | Description |
|---|---|
| Azure Login | Authenticates to Azure for KeyVault access |
| Get Secrets | Pulls secrets from Azure KeyVault into the runner environment |
| Replace Tokens | Substitutes #{TOKEN}# placeholders in config files with real values |
| WireGuard VPN | Connects to home network via WireGuard tunnel |
| Add Routes | Adds a route to the target host over WireGuard |
| SSH Setup | Configures SSH keys, pinned host fingerprints, and keepalive settings |
| Ansible Playbook | Runs provision.yml --limit {env} against the target host |
| Deployment Report | Parses PLAY RECAP into a summary table with collapsible full log |
| Disconnect | Tears down the WireGuard tunnel (always runs) |
File: .github/workflows/updates.yml
Trigger: Scheduled every Tuesday at 02:00 UTC (cron: '0 2 * * 2'), or manual dispatch.
Runs the updates.yml playbook against the test environment to apply OS package updates safely.
| Step | Description |
|---|---|
| Azure Login | Authenticates to Azure for KeyVault access |
| Get Secrets | Pulls secrets from Azure KeyVault into the runner environment |
| Replace Tokens | Substitutes #{TOKEN}# placeholders in config files |
| WireGuard VPN | Connects to home network via WireGuard tunnel |
| Add Routes | Adds a route to the target host over WireGuard |
| SSH Setup | Configures SSH keys and pinned host fingerprints |
| Ansible Playbook | Runs updates.yml --limit {env} to apply updates |
To avoid storing secrets in any files I use a combination of environment variables GitHub Secrets, and Azure KeyVault.
Secrets are never stored in the repository. The workflow uses a two-layer approach:
- GitHub Environment Secrets — SSH keys, WireGuard config, host fingerprints
- Azure KeyVault — All application secrets (Samba passwords, SSH public keys, etc.)
Secrets are injected at deploy time using token replacement:
- Config files contain
#{TOKEN}#placeholders - The Replace Tokens action substitutes them with matching environment variables sourced from KeyVault
- All GitHub Actions are pinned to commit SHAs to prevent supply-chain attacks
- Dependabot monitors for new versions weekly
- SSH connections use pinned host fingerprints (no trust-on-first-use)
- The runner connects via WireGuard VPN — no ports exposed to the internet
- Azure SPN credentials use scoped access to a single KeyVault
| Repo | Purpose |
|---|---|
| nas-docker | Docker Compose stacks deployed on the NAS |