nOS
Health Warn
- License — License: NOASSERTION
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 5 GitHub stars
Code Fail
- rm -rf — Recursive force deletion command in .github/workflows/ci.yml
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
An opensource agentic system infrastructure - not an OS
nOS — the engine behind AIT
One stack. Forty services. Zero SaaS bills.
nOSis the open-source integration engine behind This is AIT — Agentic IT.
An Ansible playbook that orchestrates 45+ roles, wires 40+ FOSS services together through one SSO,
and turns an Apple Silicon Mac into a reproducible, self-hosted, self-managing cloud.
thisisait.eu · Quick start · Stack · Architecture · SSO & RBAC · Configuration
What it is
nOS is the reference implementation of AIT — Agentic IT: a new category of self-hosted, agentic,
open-source infrastructure that collapses the SaaS stack back onto a single machine on your desk.
- One command wipes a Mac, installs 40+ services, integrates them, and secures them.
- One SSO (Authentik) fronts every app — OIDC where possible, forward-auth where not.
- One vault (Infisical) owns every secret. Per-tenant personal vaults via Vaultwarden.
- One agent (OpenClaw + Ollama MLX) runs DevOps tasks locally, no API key required.
- One command again brings everything back on a fresh box —
blank=true, ~20 minutes.
This is not a homelab hobby. It's what replaces Notion + GitHub + 1Password + Vercel +
Grafana Cloud + Auth0 + Slack + Zoom for a developer or a small team.
What it replaces
| You're paying for | You could self-host | via nOS role |
|---|---|---|
| Notion / Confluence | Outline, HedgeDoc, BookStack | pazny.outline, pazny.hedgedoc, pazny.bookstack |
| GitHub / GitLab.com | Gitea, GitLab CE + Woodpecker CI | pazny.gitea, pazny.gitlab, pazny.woodpecker |
| 1Password / LastPass | Vaultwarden (personal) + Infisical (infra) | pazny.vaultwarden, pazny.infisical |
| Auth0 / Okta | Authentik (OIDC + forward-auth + RBAC) | pazny.authentik |
| Grafana Cloud / Datadog | Grafana + Prometheus + Loki + Tempo + Alloy | pazny.grafana, pazny.prometheus, pazny.loki, pazny.tempo |
| ChatGPT / Claude.ai | Open WebUI + Ollama (MLX on Apple Silicon) | pazny.open_webui, pazny.openclaw |
| Slack / Discord | Hermes cross-channel gateway (optional) | pazny.hermes |
| Dropbox / Google Drive | Nextcloud + OnlyOffice | pazny.nextcloud, pazny.onlyoffice |
| Zapier / Make | n8n + Node-RED | pazny.n8n, pazny.nodered |
| Linear / Jira | ERPNext, FreeScout | pazny.erpnext, pazny.freescout |
| QuickBooks | Firefly III | pazny.firefly |
| Metabase Cloud | Metabase, Superset | pazny.metabase, pazny.superset |
| Netflix / Plex Pass | Jellyfin | pazny.jellyfin |
| Portainer Business | Portainer CE | pazny.portainer |
Hardware and electricity not included. A Mac Mini M4 pays for itself in under a year for a typical power user.
Quick start
Target: macOS on Apple Silicon (M1+). Intel Macs are not supported.
Recommended: 36 GB RAM, 1 TB external SSD (nOS tiers heavy data onto it automatically).
1. Bootstrap
git clone https://github.com/thisisait/nOS.git ~/nOS
cd ~/nOS
./bootstrap.sh # Xcode CLT → Homebrew → Ansible → Galaxy roles → config scaffolding
2. Configure
Bootstrap creates two gitignored files — config.yml and credentials.yml. Edit both:
$EDITOR config.yml # feature toggles: which services to install
$EDITOR credentials.yml # set global_password_prefix, override per-service secrets
Passwords follow the pattern {global_password_prefix}_pw_{service}. Override any you care
about in credentials.yml; the rest are derived. Generator: openssl rand -hex 32.
3. Run
# Full install (sudo prompt via vars_prompt — no -K needed)
ansible-playbook main.yml
# Clean reinstall — wipes ALL data and secrets, prompts for a new prefix, rebuilds from scratch
ansible-playbook main.yml -e blank=true
# Run a single stack
ansible-playbook main.yml --tags "stacks,observability"
A full first run takes ~20 minutes on an M4 Pro with fast internet.
Stack bring-up tuning
Stacks come up with docker compose up -d (non-blocking), then an in-stream
health-wait heartbeat (tasks/stacks/wait-stacks-healthy.yml →files/anatomy/scripts/stack-health-probe.py) polls every container until it
reports healthy. Each ~15s tick prints a per-stack readiness line into the mainansible.log — e.g. iiab: 17/18 ready (waiting: jellyfin[starting]) — so a
long bring-up no longer freezes the log. The wait is STRICT: every container
must reach healthy, no tolerance escape hatch. Slow services just need a generous
timeout.
| Var | Default | What it does |
|---|---|---|
stack_up_parallel |
true |
Bring wave-2 stacks up concurrently. Set false to bring them up one at a time — required for a cold blank that enables everything (parallel pulls/builds saturate the Docker daemon and blow the per-stack timeout). |
stack_up_wait_timeout |
540 |
Per-stack health budget in seconds. Bump to 900–1200 for cold-blank heavy sets (GitLab/OnlyOffice cold init is slow). |
stack_wait_tick_interval |
15 |
Seconds between health-poll ticks (heartbeat freshness vs task-count noise). |
Testing the full catalogue
profiles/all-on.yml is a committed test profile that enables every known-good
service (excludes erpnext / freepbx / spacetimedb), forces sequential
bring-up, and sets a 1200s per-stack timeout:
ansible-playbook main.yml -e @profiles/all-on.yml # everything on
ansible-playbook main.yml -e @profiles/all-on.yml -e blank=true # full wipe + reinstall
-e @profiles/all-on.yml layers on top of your gitignored config.yml /credentials.yml without touching secret overrides.
Autonomous (sudo-free) stack runs
tools/nos-stacks.sh brings the Docker stack layer up without sudo and
without the interactive prompt — for agent / CI-driven dev. The compose-up
flow carries zero become: tasks, and -e nos_sudo_password='' skips thevars_prompt:
tools/nos-stacks.sh # all stacks (core + wave-2)
tools/nos-stacks.sh woodpecker # render + recreate one service (A17)
tools/nos-stacks.sh observability # one stack
It refuses blank=true (that path needs sudo + a human).
Known first-run notes
- GitLab cold init is slow (~12 min to first healthy) but converges under
the strict health-wait — give it a generousstack_up_wait_timeout.
The stack
Every Docker service is owned by an Ansible role under roles/pazny.*. Services are
grouped into 8 Docker Compose stacks that boot in dependency order.
| Stack | Role count | Services |
|---|---|---|
| infra (always on, always first) | 9 | MariaDB, PostgreSQL, Redis, Portainer, Traefik, Authentik (server + worker), Infisical, Bluesky PDS |
| observability (always on, always second) | 4 | Grafana, Prometheus, Loki, Tempo (+ Alloy as unified collector on the host) |
| iiab — Internet-in-a-Box & productivity | 12 | WordPress, Nextcloud, n8n, Kiwix, Jellyfin, Open WebUI, Uptime Kuma, Calibre-Web, Home Assistant, RustFS, Puter, Vaultwarden |
| devops | 5 | Gitea, Woodpecker CI, GitLab CE, Paperclip, code-server |
| b2b | 7 | ERPNext, FreeScout, Outline, HedgeDoc, BookStack, Firefly III, OnlyOffice |
| voip | 1 | FreePBX (Asterisk) |
| engineering | 1 | QGIS Server |
| data | 2 | Metabase, Apache Superset |
Non-Docker services installed directly on the host: OpenClaw (launchd agent daemon),
Wing (Nette PHP security dashboard), Bone (local management REST bridge),
IIAB Terminal (Python Textual TUI over SSH).
Architecture
Compose-override pattern
Each role owns a single Docker Compose fragment that is merged into its stack at runtime:
roles/pazny.<service>/
defaults/main.yml # version, port, data_dir, mem_limit
tasks/main.yml # render override file into ~/stacks/<stack>/overrides/
tasks/post.yml # (optional) API calls, DB init, admin bootstrap
templates/compose.yml.j2 # service definition — no top-level networks:
handlers/main.yml # (optional) restart handler
Stack orchestrators (tasks/stacks/core-up.yml, stack-up.yml) discover overrides withansible.builtin.find and pass them as -f flags to docker compose up:
docker compose \
-f ~/stacks/iiab/docker-compose.yml \
-f ~/stacks/iiab/overrides/wordpress.yml \
-f ~/stacks/iiab/overrides/nextcloud.yml \
... \
up iiab -d
Bring-up is non-blocking (up -d); a separate in-stream health-wait heartbeat
then polls every container to healthy (see Stack bring-up tuning below).
Result: each service stays in its own role, but the stack sees one merged compose. Add a
service by creating a role — no hand-edits to the base stack template.
Boot order
- Password prefix prompt (on
blank=true) - Blank reset — wipes Docker, data dirs, external SSD paths
- Auto-enable dependencies — flips on MariaDB/PostgreSQL/Redis based on which services are on
- Auto-generate secrets — Outline, Bluesky, Authentik bootstrap token, Infisical, Vaultwarden, Paperclip
- Host-level roles — Xcode CLT → Homebrew → dotfiles → Mac App Store → Dock
- Host tasks — macOS defaults, SSH, language runtimes (PHP, Node, Python, Go, .NET, Bun), Nginx, external storage tiering
- Core stacks up —
infra+observability(always required, always first) - Post-start core — Authentik blueprints + OIDC app provisioning, Infisical init, PDS bootstrap, Portainer admin + OAuth
- Remaining stacks up —
iiab,devops,b2b,voip,engineering,data - Post-start services — admin users, DB migrations, OIDC wiring, onboarding
- Tier-2 apps stack —
pazny.apps_runnerdiscoversapps/<name>.ymlmanifests,
validates them (GDPR Article 30 + TLS / SSO / EU-residency gates), renders
a merged compose override, brings the apps stack up, fires observability
hooks. See docs/tier2-app-onboarding.md. - Post-provision — stack health verification, service registry
Invariant: post-start tasks can assume MariaDB, PostgreSQL, Authentik, Infisical,
Grafana, Loki, and Tempo are already online.
Reverse proxy
Traefik in a container is the default edge proxy (binds 80/443) as of C1
(2026-04-29). Two providers: file (auto-derived from state/manifest.yml
for the 50+ existing Tier-1 services) and docker (auto-emitted labels
for Tier-2 apps_runner manifests). Authentik forward-auth wires through
the authentik@file middleware. Host nginx is opt-in viainstall_nginx: true and lives behind the same tasks/nginx.yml. See
docs/traefik-primary-proxy.md.
State & Migration Framework
Long-lived installs get a first-class answer to "what's running, how do I safely change
it, and how do I roll back". Four surfaces: declarative state
(state/manifest.yml + ~/.nos/state.yml), global migrations
(migrations/*.yml, auto-applied in pre_tasks), per-service upgrade recipes
(upgrades/*.yml, including pg_upgrade / mariadb-upgrade / Grafana
dashboard-preserving patterns), and dual-version coexistence for zero-downtime major
upgrades. Every action emits structured events to Wing
(/migrations, /upgrades, /timeline, /coexistence views).
See docs/framework-overview.md for the operator tour,
and docs/framework-plan.md for the authoritative spec.
Notifications
Every service plugin carries a canonical A9 notification-routing block
(on_critical / on_high / on_medium / on_low / on_info → channelswing-inbox | ntfy | mail) — uniform across all 55 plugins. The wing-base
aggregator harvests them into a routing sidecar that Bone reads at insert time,
fanning events out to the Wing inbox, ntfy push, and SMTP (Stalwart, with a
daily digest at a configurable severity floor). See
files/anatomy/docs/notification-fanout.md.
SSO & RBAC
Single sign-on is not optional in nOS — most services are fronted by Authentik atauth.<tld> (default auth.dev.local). Each Tier-1 plugin underfiles/anatomy/plugins/<svc>-base/plugin.yml carries its own authentik: block;
the authentik-base aggregator harvests them into inputs.clients and the plugin
loader renders them into the live Authentik blueprint at deploy time. Post-start
tasks auto-provision OIDC providers and applications for every enabled service.
Integration modes
| Mode | Services | How |
|---|---|---|
| Native OIDC (env) | Grafana, Outline, Open WebUI, n8n, GitLab, Vaultwarden | OIDC env vars in the compose override |
| Native OIDC (API/CLI) | Gitea, Nextcloud, Portainer | Admin API / occ / PUT /api/settings |
| Proxy auth (forward-auth) | Uptime Kuma, Calibre-Web, Home Assistant, Jellyfin, Kiwix, WordPress, ERPNext, FreeScout, Infisical, Paperclip, Superset, Puter, Metabase | Nginx auth_request + Authentik embedded outpost |
| Identity bridge | Bluesky PDS | Authentik → PDS auto-provisions @user.bsky.<tld> accounts |
| No SSO | FreePBX, QGIS | Service owns its own auth |
Proxy auth gates access but each service still renders its own login. Native OIDC gives
you a real "Sign in with Authentik" button.
RBAC tiers
Four access tiers, bound to Authentik groups via expression policies. Every app's tier is
declared in authentik_app_tiers; users are added to the corresponding nos-* group
(installs provisioned before 2026-04-22 use the legacy devboxnos-* prefix — rename the groups in Authentik or run blank=true to regenerate).
| Tier | Role | Scope | Example services |
|---|---|---|---|
| 1 | admin | Infra, secrets, monitoring | Portainer, Infisical, Grafana, Wing, InfluxDB |
| 2 | manager | Dev tools, analytics, automation | Gitea, GitLab, n8n, Superset, Metabase, Paperclip, ERPNext, FreeScout |
| 3 | user | Employee productivity | Nextcloud, Outline, Open WebUI, Puter, Vaultwarden, Uptime Kuma, Home Assistant, Calibre-Web |
| 4 | guest | Public/content | Kiwix, Jellyfin, WordPress |
Configuration
Layering
Four files, later overrides earlier:
default.config.yml ← all variables with defaults (committed)
default.credentials.yml ← all secrets as {{ prefix }}_pw_* templates (committed)
config.yml ← your feature toggles (gitignored)
credentials.yml ← your secret overrides (gitignored)
Installation queue
The top of default.config.yml is a flat list of ~78 boolean toggles — comment out
anything you don't want:
install_nginx: true
install_openclaw: true # AI agent + Ollama MLX
install_observability: true # LGTM stack (required for audit trail)
install_wordpress: false
install_nextcloud: true
install_gitea: true
install_gitlab: false # heavy (~4 GB RAM); only enable if you need CI at scale
install_open_webui: true
# ... 70 more
Version policy
ansible-playbook main.yml # stable (default, CVE-patched)
ansible-playbook main.yml -e version_policy=latest # track upstream latest
ansible-playbook main.yml -e version_policy=lts # LTS branches where available
./security-update.sh # security-only pull
Per-service override: set {service}_version in config.yml.
Instance identity
Every nOS box has a unique identity so a provider (e.g. thisisait.eu) can manage a fleet:
instance_name: "nos" # unique slug
instance_tld: "dev.local" # every service lives at <service>.<tld>
instance_role: "standalone" # standalone | headquarters | factory | office | division
instance_parent: "" # slug of parent box for hierarchy
Tags & selective runs
ansible-playbook main.yml --tags "TAG[,TAG…]"
| Tag | What runs |
|---|---|
stacks |
All Docker stacks |
core, infra, observability |
Core stacks only |
nginx |
Nginx + vhosts + mkcert certs |
php, node, python, go, dotnet, bun |
Single language runtime |
openclaw, hermes, ai |
AI agents |
wing, security |
Wing security dashboard |
iiab-terminal, ssh |
SSH + ForceCommand TUI |
bone, api |
Local FastAPI (structure / state / dispatcher) |
dnsmasq, dns, network |
*.<tld> resolver |
tailscale |
VPN |
external-storage, storage |
Tier data onto /Volumes/* |
macos-defaults, osx |
Finder / Dock / keyboard / screenshot prefs |
backup |
Restic backup config |
heartbeat, fleet |
Fleet reporting daemon |
blank, reset |
Blank-wipe tasks (requires -e blank=true) |
Dry run: --check. Syntax only: ansible-playbook main.yml --syntax-check.
External storage
If configure_external_storage: true and an SSD is mounted at external_storage_root
(default /Volumes/SSD1TB), heavy data directories are bind-mounted onto it — GitLab,
Ollama models, observability databases, media libraries, Docker Desktop disk image location,
language caches:
/Volumes/SSD1TB/
├── cache/{npm,pip,composer,homebrew}/
├── docker/ # Docker Desktop disk image (manual move via Settings)
├── gitea/ gitlab/ woodpecker/
├── ollama/models/ # ~17 GB per LLM
├── observability/{prometheus,loki,tempo}/
├── media/ jellyfin/
├── kiwix/ maps/ # ZIMs + MBTiles
├── nextcloud-data/ wordpress/ calibre/
└── n8n/ openwebui/ portainer/ uptime-kuma/
blank=true honors these paths — it wipes the real data, not just empty ~/service
fallback directories.
Adding a new service
Tier-1 (full role + plugin):
- Scaffold the role under
roles/pazny.<service>/following the compose-override pattern. - Wire it into the right stack orchestrator (
tasks/stacks/core-up.ymlorstack-up.yml)
withinclude_role— remember bothapply: { tags: […] }andtags: […]on the
task so--tagsfiltering works. - Add
install_<service>: falsetodefault.config.yml. - Create
files/anatomy/plugins/<service>-base/plugin.ymlwith anauthentik:block
(mirror an existing sibling such asgrafana-base/plugin.yml). The plugin loader
harvests it into the Authentik blueprint automatically. - Add a row to
state/manifest.ymlwithdomain_var+port_varso Traefik's
file-provider auto-routes the service.
Tier-2 (manifest-only — no role): drop a YAML at apps/<service>.yml and re-run the
playbook. See docs/tier2-app-onboarding.md for the
GDPR-gated manifest schema and Coolify import flow.
The full doctrine for both paths lives in CLAUDE.md §Adding a new
Docker service.
Manual steps macOS can't automate
| # | What | Why |
|---|---|---|
| 1 | tailscale up |
Interactive browser login |
| 2 | System Settings → Keyboard → Modifier Keys → Caps Lock → Escape | Per-keyboard, not scriptable |
| 3 | System Settings → Privacy & Security → Full Disk Access → add Terminal / Ghostty | SIP-protected |
| 4 | Docker Desktop → Settings → Resources → Disk image location → /Volumes/SSD1TB/docker/ |
GUI-only |
mkcert CA install and *.<tld> DNS are automated via dnsmasq + /etc/resolver/<tld>.
Project layout
nOS/
├── main.yml # entry point: handlers + imports
├── bootstrap.sh bootstrap/ # Xcode CLT → Homebrew → Ansible → config scaffolding
├── default.config.yml # all variables (committed)
├── default.credentials.yml # secret templates (committed)
├── config.yml credentials.yml # your overrides (gitignored)
├── requirements.yml # Galaxy role dependencies
├── inventory # Ansible inventory (localhost)
├── security-update.sh # security-only image pull
│
├── roles/pazny.<service>/ # 57 roles, one per service
│ ├── defaults/ tasks/ handlers/ templates/ meta/
│ └── templates/compose.yml.j2 # compose-override fragment
│
├── tasks/
│ ├── stacks/
│ │ ├── core-up.yml # infra + observability (always first)
│ │ ├── stack-up.yml # iiab, devops, b2b, voip, engineering, data
│ │ ├── authentik_service_post.yml
│ │ ├── bluesky_pds_bridge.yml
│ │ ├── external-paths.yml # honor /Volumes/SSD1TB overrides
│ │ └── shared-network.yml
│ ├── blank-reset.yml # wipes Docker, data, configs
│ ├── nginx.yml php.yml node.yml python.yml golang.yml dotnet.yml bun.yml
│ ├── observability.yml # Alloy + scrape targets
│ ├── macos-defaults.yml osx.yml
│ ├── backup.yml heartbeat.yml vulnerability-scan.yml
│ ├── system-services.yml tailscale.yml dnsmasq.yml
│ ├── external-storage.yml power-management.yml
│ └── service-registry.yml export-state.yml import-state.yml
│
├── templates/
│ ├── stacks/{infra,observability,iiab,devops,b2b,voip,engineering,data}/
│ │ └── docker-compose.yml.j2 # base stack (services: {} + networks)
│ └── nginx/sites-available/ # 50 vhosts
│
├── files/ # static assets (configs, dashboards, icons)
├── docs/ # architecture notes, fleet-architecture.md
└── tests/
Known tech debt
ansible_env→ansible_facts.envmigration needed before Ansible-core 2.24- Bluesky PDS federation needs public DNS to be fully functional (account bridge works locally)
- ERPNext first-run migration sometimes fails;
erpnext_post.ymlhas an auto-retry - Jellyfin / Open WebUI may restart-loop on first DB init until data regenerates — expected
- Mattermost removed (no ARM64 FOSS image)
Contributing
The repo is public. The category isn't written yet. Help us define it.
- Star the repo → it's how open source gets found
- File issues with real traces —
docker compose logs,ansible-playbook -vv - PRs follow Conventional Commits (
feat:,fix:,refactor:…). NoCo-Authored-By, no--author. Subject ≤ 50 chars, body bullets ≤ 6 lines. - Branch model:
feat/<name>→dev(FF from CLI) →master(PR + FF only, locked on GitHub + local Gitea mirror). Tagsv<semver>cut frommaster. Don't push directly tomaster.
Origin & license
Main inspiration: IIAB - internet in a box
Forked from geerlingguy/mac-dev-playbook
by Jeff Geerling, author of
Ansible for DevOps.
MIT Licensed. Sent from my Mac Studio. Built by humans, maintained by agents.
Website: thisisait.eu
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found