Tailscale-first dev / pentest / AI station for Ubuntu 24.04 + Docker.
Highlights
- Runtime contract —
scripts/lib/devbox-contract.shis the single source of truth for install layout, service identities, container names, Traefik hosts, named volumes, backup targets, and the pinned alpine backup image.setup.shinstalls the contract; helpers source it; Bats tests pin its shape. - Tailscale-only HTTP and (optionally) HTTPS — no public
0.0.0.0bind in either mode. HTTPS uses an opt-in Compose overlay + OVH DNS-01 cert. See ARCHITECTURE.md. - Per-install Ollama basic-auth credential — random 32-char password generated by
setup.sh, plaintext at~/docker/.secrets/ollama-auth.txt(0600), rotatable viarotate-ollama-auth.sh. Open WebUI uses internal Compose DNS, no auth crossing. - Image digest lockfiles committed per service. Setup.sh emits the compose chain (
docker-compose.yml[:docker-compose.https.yml][:docker-compose.lock.yml]) via the contract. CI gates onupdate-images.sh --check. - Verified downloads — every
curl|shrouted throughfetch_and_verifywith SHA256 pinned inscripts/lib/download-manifest.sh. Emergency override:DEVBOX_ALLOW_UNVERIFIED=1. - Signed weekly release tarball — cosign keyless + CycloneDX SBOM + SLSA Build L3 provenance. Fork-friendly CI (conditional Docker Hub login). Alpine digest auto-PR (never auto-merges main).
- Systemd integration —
devbox.service(oneshot + RemainAfterExit) and dailydevbox-backup.timer, rendered from contract values. Installed viamake install-systemd. - Operator Makefile —
help|doctor|test|lint|compose-check|start|stop|status|backup|security-check|rotate-ollama-auth|install-systemd|uninstall-systemd. - Spoof-resistant collision guard —
setup.shrefuses to install into a non-devbox~/docker/unless both the contract and a marker file are present (upgrade-in-place). - Honest privilege model —
devis in thedockergroup (root-equivalent for socket access). Security boundary = Tailscale ACL + SSH key auth + UFW default-deny. See docs/security.md. - 35 contract bats tests + 55 unit bats tests gate every PR via
.github/workflows/pr-validate.yml.
- Overview
- Architecture diagram
- Requirements
- Quick start
- Configuration
- Post-installation
- Reproducibility
- Operator commands
- Penetration testing workflow
- Security model
- Troubleshooting
- Directory structure
- Documentation index
- License
DevBox provisions Ubuntu 24.04 with a hardened, reproducible stack: Traefik behind docker-socket-proxy, Ollama + Open WebUI for local LLM inference, and optional Exegol for pentest work. Every service is bound to the Tailscale IP — no public listener beyond SSH on a custom port.
Two access paths are supported:
- Path A — Tailscale-only HTTP (default). Port 80 bound to the Tailscale IP. No domain or certificates needed. HTTP over WireGuard is already encrypted.
- Path B — Tailscale-only HTTPS (opt-in). Port 443 also bound to the
Tailscale IP (no
0.0.0.0listener). Let's Encrypt wildcard cert via OVH DNS-01. RequiresENABLE_HTTPS=true,DEVBOX_DOMAIN, and~/.config/devbox/ovh.envwith OVH API credentials. Certificate is publicly issued; listener is reachable only through Tailscale.
Path A (default) Path B (opt-in)
──────────────── ───────────────
[Your Devices] ──Tailscale──> [Traefik :80] [Your Devices] ──Tailscale──> [Traefik :443]
| |
(Tailscale IP) | (Tailscale IP, TLS) |
v v
+---------------------------[VPS]-------------------------------------------+
| |
| [Tailscale] <──> [Your Devices] (no public binding in either path) |
| | |
| v |
| [Traefik] ──────+──── [Open WebUI] ai.internal / ai.DOMAIN |
| | | |
| | +──── [Ollama API] ollama.internal (basicAuth) |
| | | |
| | +──── [Traefik Dashboard] traefik.internal (basicAuth) |
| | |
| +──> [Docker Socket Proxy] ──> /var/run/docker.sock |
| (internal network, read-only API subset) |
| |
| [Exegol Container] <──> [HTB/THM VPN] (optional, upstream exegol CLI) |
| (noVNC bound via UFW to tailscale0 only) |
| |
+---------------------------------------------------------------------------+
For the load-bearing design decisions (trust model, install layout, contract, HTTPS overlay, lockfile workflow, systemd integration, snapshot recovery, etc.) see ARCHITECTURE.md.
- Ubuntu 24.04 LTS.
- Docker + Docker Compose plugin (v2.22+) pre-installed.
- 4 GB RAM minimum; 24 GB+ recommended for Ollama with larger models.
- Root access for the initial setup run.
- SSH public key for authentication.
Production installs: Prefer the signed weekly release tarball — it ships with cosign keyless signature, SHA256 digest, CycloneDX SBOM, and SLSA provenance. See Install from a signed release tarball in
docs/updating.mdfor the verified-download sequence.The
git clonepath below is the dev / contributor path. No signature verification, tracksmainHEAD.
git clone https://github.com/gl0bal01/devbox.git
cd devbox
# Optional: override defaults via env
export DEVBOX_USER=dev
export DEVBOX_SSH_PORT=5522
export SSH_PUBLIC_KEY="$(cat ~/.ssh/id_ed25519.pub)"
# Optional: HTTPS mode — create OVH credentials first
install -d -m 0700 ~/.config/devbox
cat > ~/.config/devbox/ovh.env <<EOF
OVH_ENDPOINT=ovh-eu
OVH_APPLICATION_KEY=your-key
OVH_APPLICATION_SECRET=your-secret
OVH_CONSUMER_KEY=your-consumer-key
EOF
chmod 600 ~/.config/devbox/ovh.env
export ENABLE_HTTPS=true
export DEVBOX_DOMAIN=example.com
# Inspect every Phase without touching the host
./setup.sh --dry-run
# Install
sudo ./setup.shAfter install, follow Post-installation.
Environment variables override the defaults in setup.sh:
| Variable | Default | Purpose |
|---|---|---|
DEVBOX_USER |
dev |
Operator account created by setup.sh |
DEVBOX_EMAIL |
admin@example.com |
Let's Encrypt registration email |
DEVBOX_SSH_PORT |
5522 |
SSH port |
DEVBOX_DOMAIN |
example.com |
Domain (HTTPS mode only) |
ENABLE_HTTPS |
false |
Opt-in to Tailscale-only HTTPS |
SSH_PUBLIC_KEY |
(unset) | Your SSH pubkey string |
Setup.sh CLI flags:
| Flag | Effect |
|---|---|
--dry-run |
Print every Phase + an rsync --dry-run per service. No mutations. No root needed. |
--check |
Source the contract; report whether the installed contract matches the repo copy. No root needed. |
--yes / -y |
Skip interactive prompts (continue confirm, UFW reset). Unattended install. |
--help / -h |
Print usage + exit. |
HTTPS mode requires ~/.config/devbox/ovh.env (mode 0600) — setup.sh
refuses ENABLE_HTTPS=true without it. OVH credentials never live in tracked
source. See ARCHITECTURE.md for the trust rationale.
setup.sh reads your SSH public key from (in order):
SSH_PUBLIC_KEY="ssh-ed25519 AAAA..."env var.~/.ssh/devbox_authorized_keyor/root/.ssh/devbox_authorized_key.- Manual: add to
~/.ssh/authorized_keysafter setup completes.
From a NEW terminal on your local machine (do not close the setup terminal until this works):
ssh -p 5522 dev@YOUR_SERVER_IPsudo tailscale up --accept-routes --advertise-tags=tag:devboxcd ~/docker
./start-all.shLockfiles are committed in services/*/docker-compose.lock.yml. The
emitted compose chain in ~/.config/devbox/config.env already includes
them; helpers filter chain entries by [ -f ... ] so a missing overlay
never blocks startup. To verify lockfile freshness:
./scripts/update-images.sh --check # exit 0 = in syncAdd to /etc/hosts on your local machine (replace with your Tailscale IP):
100.X.Y.Z ai.internal traefik.internal ollama.internal exegol.internal
Get your Tailscale IP with tailscale ip -4.
docker exec -it ollama ollama pull llama3.2
docker exec -it ollama ollama pull codellamaExternal clients of http://ollama.internal (Tailscale-only) authenticate
with basic auth. The per-install credential is at:
~/docker/.secrets/ollama-auth.txt # mode 0600, format: ollama:<password>
Rotate with ~/docker/rotate-ollama-auth.sh. Open WebUI is unaffected —
it talks to Ollama on the internal Compose network (http://ollama:11434,
no auth).
~/docker/install-ai-dev-stack.sh # interactive menu
~/docker/install-ai-dev-stack.sh --all # install all
~/docker/install-ai-dev-stack.sh --status
~/docker/install-ai-dev-stack.sh --updateTools shipped: Claude Code, OpenCode, Goose, LLM, Fabric.
sudo make install-systemd
# systemctl enable: devbox.service + devbox-backup.timer (not auto-started)
sudo systemctl start devbox.service
sudo systemctl list-timers devbox-backup.timerdevbox.service is Type=oneshot RemainAfterExit=yes — it calls
start-all.sh on boot and stop-all.sh on shutdown. The daily backup
timer runs backup.sh with Persistent=true RandomizedDelaySec=1800.
~/docker/security-check.shThree install modes are documented in docs/updating.md:
| Mode | How | Signed? | Smoke-tested? | Recommended for |
|---|---|---|---|---|
latest |
git pull origin main |
No | No | Dev / contribution only |
weekly-YYYYMMDD |
gh release download weekly-... + cosign verify |
Yes | Yes | Production |
@sha256:<digest> |
Pin release tarball by SHA256 | Yes | Yes | Maximum paranoia |
The weekly job at .github/workflows/weekly-rebuild.yml builds a deterministic
devbox.tar, signs with cosign keyless (GitHub OIDC + Sigstore Rekor), attaches
a CycloneDX SBOM + SLSA Build L3 provenance, and publishes via
gh release create. A separate alpine-digest-check job opens a PR on alpine
digest drift — never auto-merges.
gh release download weekly-20260420 \
-p devbox.tar -p devbox.tar.sha256 \
-p devbox.tar.sig -p devbox.tar.pem
sha256sum -c devbox.tar.sha256
cosign verify-blob devbox.tar \
--signature devbox.tar.sig \
--certificate devbox.tar.pem \
--certificate-identity 'https://github.com/gl0bal01/devbox/.github/workflows/weekly-rebuild.yml@refs/heads/main' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'Both --certificate-identity AND --certificate-oidc-issuer are required —
omitting either accepts any GitHub Actions workflow as a valid signer.
Committed docker-compose.lock.yml files target x86_64 (the CI runner
architecture). Operators on arm64 must regenerate lockfiles locally with
./scripts/update-images.sh --apply before start-all.sh. See
docs/updating.md.
If Sigstore (Rekor/Fulcio) is down, CI fails closed and no weekly-* tag is
cut. Emergency path: git checkout main && sudo ./setup.sh. Treat as
untrusted and re-verify by re-running weekly-rebuild once Sigstore recovers.
The Makefile is the single entry point.
make help # one-line summary of every target
make doctor # preflight: docker daemon, tailscale, config.env, contract, marker
make test # lint + compose-check + anchor-check + docs-tree + contract bats
make lint # shellcheck + bash -n + 55 unit bats
make compose-check # validate compose configs (HTTP + HTTPS for every service)
make start # ${HOME}/docker/start-all.sh (refuses without install marker)
make stop # ${HOME}/docker/stop-all.sh
make status # ${HOME}/docker/status.sh
make backup # named-volume tar via alpine@sha256 + acme.json + .env files
make security-check # 10 hardening assertions
make rotate-ollama-auth # regenerate per-install ollama basic-auth credential
make install-systemd # sudo — install + enable devbox.service + devbox-backup.timer
make uninstall-systemd # sudo — stop, disable, remove unitsInstall-dependent targets gate on the install marker at
~/docker/lib/.devbox-marker and refuse with a diagnostic message when
absent. make doctor always runs.
# Connect to HTB VPN (PID-file based, no global pkill)
htb-vpn ~/htb/lab.ovpn
htb-vpn status
# Start Exegol with desktop (random per-container password)
exegol # Default: exegol-htb on port 45377
exegol osint-box --port 45378 # Multiple containers on different ports
# Rotate VNC password for a running container
~/docker/exegol-reset-vnc.sh exegol-htb
# Access: http://exegol.internal:45377/vnc.html
# Username: root, password printed once at container start.
# UFW restricts the port to the tailscale0 interface only.See docs/exegol.md for the full workflow. Exegol uses the
upstream exegol CLI — no Compose stack under services/.
Per docs/security.md and ARCHITECTURE.md:
devis in thedockergroup. This is equivalent to root for anyone who compromises thedevaccount (docker socket → bind-mount/). The security boundary is Tailscale ACL + SSH key auth + UFW default-deny, not local privilege separation.
Sudoers whitelist (only commands NOT handled by the docker boundary):
dev ALL=(root) NOPASSWD: /usr/sbin/ufw, /usr/bin/tailscale, /usr/sbin/openvpn
dev ALL=(root) NOPASSWD: /bin/systemctl restart docker, /bin/systemctl reload ufw
dev ALL=(root) PASSWD: ALL
- Only SSH (default port 5522) is exposed to the public internet.
- Traefik ports 80 and (optional) 443 bound to the Tailscale IP — inaccessible from LAN.
- All
.internalURLs require Tailscale. - Exegol noVNC port: container-internal
0.0.0.0, host-side UFW rule scoped totailscale0.
| Measure | Implementation |
|---|---|
| Secrets management | .env files mode 0600, rendered at install via whitelisted envsubst |
| Docker socket protection | Traefik uses tecnativa/docker-socket-proxy (read-only API subset) |
| Privilege escalation prevention | security_opt: no-new-privileges:true on every container |
| Capability dropping | cap_drop: ALL + explicit cap_add whitelist |
| Resource limits | Memory, CPU, and PID limits per container |
| Log rotation | json-file driver, 10 MB × 3 files (drift-checked by CI) |
| Stop grace period | Tuned per service (60s for Ollama to drain in-flight inference) |
| Service | Method |
|---|---|
| SSH | Key-based only (password disabled, root disabled) |
| Open WebUI | Application-level (disable signup after admin creation) |
| Traefik Dashboard | Basic Auth (dashboard-auth middleware) |
| Ollama external route | Basic Auth (ollama-auth middleware, per-install random credential) |
Three opt-in operator scripts under scripts/host/ add per-target hardening
on top of the baseline. Each is dry-run by default; --apply is required
to mutate. See docs/harden-modules.md.
sudo scripts/host/harden-dnat-scope.sh # dry-run
sudo scripts/host/harden-dnat-scope.sh --apply # scope Docker DNAT to Tailscale CGNAT
sudo scripts/host/harden-fail2ban.sh --apply # traefik-auth + recidive jails (publicly bound only)
sudo scripts/host/harden-backup-skeleton.sh \
--tag myapp --path /home/myapp --apply # age-encrypted, systemd-timed backupsudo systemctl status ssh # SSH up?
sudo ss -tlnp | grep ssh # Listening port?
docker ps # Containers up?
docker logs traefik # Traefik logs
tailscale status # Tailscale connected?
~/docker/status.sh # Full status dashboard
~/docker/diagnose.sh # Bundle full diagnostic tarballIf setup.sh blocks with "Another devbox setup.sh is already running":
sudo ls -la /var/lock/devbox-setup.lock
sudo rm /var/lock/devbox-setup.lock # only if no real setup is runningIf setup.sh refuses with a collision error, the ~/docker/ tree contains
non-devbox files. Either move them aside or pick a clean DEVBOX_HOME. The
upgrade-in-place mode triggers automatically when both
~/docker/lib/devbox-contract.sh AND ~/docker/lib/.devbox-marker are
present.
For more scenarios, see docs/troubleshooting.md and docs/ops.md.
Repository
devbox/
├── setup.sh # Host bootstrap (sources contract, rsyncs, renders, writes marker)
├── ARCHITECTURE.md # Load-bearing design decisions
├── Makefile # Operator entry point
├── README.md
├── CONTRIBUTING.md
├── LICENSE
├── config.env.example # Model for ~/.config/devbox/config.env
├── services/
│ ├── README.md # Compose conventions + rendering table
│ ├── traefik/
│ │ ├── docker-compose.yml
│ │ ├── docker-compose.https.yml
│ │ ├── docker-compose.lock.yml
│ │ ├── traefik.yml
│ │ ├── traefik.https.yml.template
│ │ ├── .env.template
│ │ ├── .env.example
│ │ └── dynamic/
│ │ ├── dashboard-auth.yml.template
│ │ ├── ollama-auth.yml.template
│ │ ├── middlewares-base.yml
│ │ ├── middlewares-https.yml
│ │ ├── middlewares-rate.yml
│ │ └── middlewares-allowlist.yml
│ └── ollama-openwebui/
│ ├── docker-compose.yml
│ ├── docker-compose.https.yml
│ ├── docker-compose.lock.yml
│ ├── .env.template
│ └── .env.example
├── scripts/
│ ├── host/ # Helpers rsynced to ~/docker/ by setup.sh
│ │ ├── start-all.sh
│ │ ├── stop-all.sh
│ │ ├── status.sh
│ │ ├── security-check.sh
│ │ ├── backup.sh # named-volume tar via alpine@sha256
│ │ ├── rotate-ollama-auth.sh # per-install credential rotation
│ │ ├── install-systemd.sh # render + enable systemd units
│ │ ├── diagnose.sh
│ │ ├── install-ai-dev-stack.sh
│ │ ├── htb-vpn.sh
│ │ ├── exegol-start.sh
│ │ ├── exegol-reset-vnc.sh
│ │ ├── harden-dnat-scope.sh # opt-in
│ │ ├── harden-fail2ban.sh # opt-in
│ │ └── harden-backup-skeleton.sh # opt-in
│ ├── install/
│ │ ├── dev-zshrc
│ │ └── mise-profile.sh
│ ├── laptop/ # Run on your laptop
│ │ ├── ollama-setup.sh
│ │ ├── zed-setup.sh
│ │ └── README.md
│ ├── lib/
│ │ ├── devbox-contract.sh # CANONICAL CONTRACT
│ │ ├── fetch-verify.sh
│ │ ├── download-manifest.sh
│ │ └── sources/ # per-upstream version refresh handlers
│ ├── systemd/
│ │ ├── devbox.service.template
│ │ ├── devbox-backup.service.template
│ │ └── devbox-backup.timer.template
│ ├── ci/
│ │ ├── lint.sh # shellcheck + bash -n + 55 unit bats + docs-tree
│ │ ├── check-compose-config.sh
│ │ ├── check-anchor-consistency.sh
│ │ ├── check-docs-tree.sh
│ │ ├── smoke-test.sh
│ │ ├── sbom-targets.sh
│ │ └── release-notes.sh
│ ├── update-images.sh # Two-pass lockfile generator
│ └── update-manifest.sh
├── tests/
│ ├── contract/
│ │ ├── contract.bats # 24 install-contract assertions
│ │ └── systemd.bats # 11 systemd-unit assertions
│ ├── unit/ # 55 bats (anchor, fetch-verify, htb-vpn, parse-image, render-env)
│ └── lib/test-helpers.bash
├── docs/
│ ├── security.md
│ ├── ops.md
│ ├── updating.md
│ ├── harden-modules.md
│ ├── https-setup.md
│ ├── exegol.md
│ ├── ollama-optimization.md
│ ├── remote-ide-setup.md
│ └── troubleshooting.md
└── .github/
└── workflows/
├── pr-validate.yml # lint + compose-check + anchor + docs-tree + contract bats
└── weekly-rebuild.yml # signed tarball + SBOM + SLSA + alpine-digest-check PR
Server (after setup.sh)
~/
├── .devbox-credentials[.gpg] # Generated credentials (delete after saving)
├── .config/devbox/
│ ├── config.env # ENABLE_HTTPS, DOMAIN, TAILSCALE_IP, DEVBOX_USER, COMPOSE_FILE_<SVC>
│ └── ovh.env # OVH credentials (HTTPS mode; mode 0600)
├── .local/share/devbox/backups/ # Pre-rsync snapshots + backup.sh archives
├── docker/ # rsynced from services/ + scripts/host/ + scripts/systemd/
│ ├── lib/
│ │ ├── devbox-contract.sh # Installed runtime authority
│ │ └── .devbox-marker # Spoof-resistant install marker
│ ├── .secrets/
│ │ └── ollama-auth.txt # Per-install plaintext credential (0600)
│ ├── traefik/
│ ├── ollama-openwebui/
│ ├── systemd/ # Unit templates for install-systemd.sh
│ ├── start-all.sh, stop-all.sh, status.sh, security-check.sh, backup.sh
│ ├── rotate-ollama-auth.sh, install-systemd.sh
│ ├── exegol-start.sh, exegol-reset-vnc.sh, htb-vpn.sh
│ └── (per-service .env files rendered from .env.template at install time)
├── projects/
└── htb/ # HTB .ovpn files
| Document | Topic |
|---|---|
| ARCHITECTURE.md | Load-bearing design decisions (replaces the old docs/adr/ tree) |
| docs/security.md | Trust model, privilege boundaries, ollama-auth rotation semantics |
| docs/ops.md | Backup/restore, incident response, snapshot recovery |
| docs/updating.md | Signed tarball install, digest refresh, alpine auto-PR |
| docs/harden-modules.md | Opt-in harden-*.sh modules (DNAT, fail2ban, age-encrypted backups) |
| docs/https-setup.md | OVH DNS-01 setup for Tailscale-only HTTPS |
| docs/exegol.md | Multi-container pentest workflows |
| docs/ollama-optimization.md | Performance tuning |
| docs/remote-ide-setup.md | Configure local IDE with remote Ollama |
| docs/troubleshooting.md | Common issues |
| services/README.md | Compose layout, rendering table, anchor pattern |
| scripts/laptop/README.md | Laptop-side setup for remote Ollama |
| CONTRIBUTING.md | How to contribute |
| Provider | Instance type | Status |
|---|---|---|
| Hostinger | KVM 8 (32 GB / 8 vCPU) | Verified |
| Hetzner | CX31 | Compatible |
| DigitalOcean | Droplet | Compatible |
| AWS | EC2 t3.medium+ | Compatible |
MIT. See LICENSE.
- Tailscale · Traefik · Ollama · Exegol · Sigstore / cosign · SLSA Framework
Last updated: 2026-05-16 (v1.0.0 — runtime contract, Tailscale-only HTTPS, per-install ollama-auth, signed weekly tarball, 35 contract bats + 55 unit bats)