Self-hosted app hosting for a single Linux server. Describe an app once in a small TOML file, and AHost runs it: a systemd service, an nginx site with HTTPS, a port from a registry, GPU assignment, optional sandboxing, and a web dashboard to start, stop, restart, and read logs.
It's built for the "one beefy box, many projects" setup (a GPU server, a homelab, a small VPS) where apps run as plain processes in their own venv, uv, conda or Node environment, not in Docker, and where the hand-written unit + nginx site + certbot routine has stopped scaling.
Status: working. CLI, root helper, adoption of legacy services, and the web dashboard are implemented and in production use on one server. Sandboxing levels and multi-process splitting are implemented but lightly exercised.
- One manifest per app (
apps/<name>.toml) is the only input. Everything else is generated. - Ports are allocated for you from a reserved range, checked against live listeners, and freed on removal.
- systemd template units (
ahost@<app>.service) with per-app overrides; multi-process apps are grouped underahost@<app>.target. - nginx sites generated from one template: WebSocket and streaming (SSE / LLM token streams) work by default,
static folders are served by nginx,
nginx -truns before every reload, and a failed test is rolled back. - Routers: put several apps under one domain by path (
apps.example.com/chat,/docs, ...). Reserve paths first and assign them later; prefixes are stripped and redirects/cookies fixed up, or passed through. - HTTPS via certbot (webroot challenge); renewal uses certbot's own timer.
- GPU assignment by card UUID, enforced at the device level, not just through
CUDA_VISIBLE_DEVICES. - Sandboxing with systemd's native options (none / standard / strict), per app.
- Dynamic DNS: optionally generates the ddclient host list from your manifests.
- Adopt existing services: an importer turns hand-written units and nginx sites into manifests, and a cutover procedure moves them one at a time with a rollback at every step.
- Web UI (Nuxt + Bun) and a CLI (
ahost) over the same core. - Least privilege: the dashboard never runs as root. Root actions go through a small helper that renders files from validated manifests and accepts only a fixed set of verbs.
apps/*.toml ──▶ ahost (reconciler, runs as user "ahost")
│ plan: diff desired vs actual
▼
ahost-helper (root, via sudo, fixed verbs)
┌───────────┼──────────────┬───────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
/etc/ahost/ systemd nginx certbot ddclient
apps/*.env ahost@<app> ahost.d/ (webroot) host list
The core operation is idempotent: make reality match the manifests. ahost plan shows what would change;
ahost apply does it. Running it twice changes nothing the second time.
# apps/blog-api.toml
name = "blog-api"
workdir = "/home/me/projects/blog-api"
command = "uvicorn main:app --host 127.0.0.1 --port $PORT"
runtime = { venv = ".venv" }
domains = ["api.example.com"]
health = "/healthz"ahost plan blog-api # port 10003 · write unit + env · write nginx site · issue cert for api.example.com
ahost apply blog-api
ahost logs blog-api -fMore examples: examples/apps/: a static site, a GPU inference server, a multi-process app.
Screenshots come from tools/demo_server.py, which runs the real dashboard against a fictional server
(uv run python tools/demo_server.py, then sign in as demo / demo-password-123). Regenerate them with
uv run --with playwright python tools/screenshots.py.
ahost.service runs the API and serves the dashboard on ui.bind (127.0.0.1). With ui.domain set, the installer
creates its nginx site and certificate (ahost ui-site). There's a password login (argon2), no default account:
sudo -u ahost ahost user add <name>Rack (live status, start/stop/restart, logs), System, Router, Ports, and New hosting (form + live plan preview).
| Doc | What's in it |
|---|---|
| docs/architecture.md | components, reconcile loop, systemd/nginx/cert/port/GPU/DNS details |
| docs/manifest.md | every manifest field |
| docs/routers.md | several apps under one domain by path |
| docs/cli.md | every ahost command |
| docs/agents.md | for AI agents and scripts: recipes and rules (also ahost guide) |
| docs/security.md | threat model, the root helper, UI auth, sandbox levels |
| docs/adopting-existing-services.md | migrating hand-written units and nginx sites safely |
| docs/ui.md | web UI spec and design direction |
| config/ahost.example.toml | server-wide settings |
/home/<you>/AHost/ this repo: platform code, templates, UI, docs
/home/<you>/apps/ your manifests (a separate git repo; server-specific, never pushed with AHost)
/etc/ahost/ahost.toml server-wide config
/etc/ahost/apps/ generated env files (0600, may hold secrets)
/etc/nginx/ahost.d/ generated nginx sites
/etc/systemd/system/ahost@.service, ahost@.target, ahost@<app>.service.d/
/var/lib/ahost/ state: port registry, apply history, users (SQLite)
ahost/ Python package: models, reconciler, ports, systemd, nginx, certs, gpu, api (FastAPI), cli (Typer)
helper/ ahost-helper: the root-side verbs (installed root-owned to /usr/local/sbin)
templates/ ahost@.service, ahost@.target, override.conf.j2, site.conf.j2, ddclient.j2
ui/ Nuxt (SPA) + Nuxt UI, built with Bun, served by the API as static files
deploy/ ahost.service, sudoers snippet, install script
examples/ example manifests
tests/ reconciler plan tests against fixture manifests (no root needed)
Linux with systemd ≥ 249 and cgroup v2, nginx, certbot, uv, Bun (UI build only). NVIDIA driver for GPU features. Tested target: Ubuntu 22.04. uv provides Python 3.12, so the system Python version doesn't matter.
All Python is managed with uv: pyproject.toml + uv.lock, Python pinned in .python-version.
uv sync # create .venv from uv.lock
uv run ahost plan # read-only: works without root
uv run pytest
uv add <package> # never pip installThe installed service and the root helper never run from a user-writable checkout. Apps run as a normal user,
so if AHost ran from that user's home, any compromised app could modify the code that runs as root.
deploy/install.sh (run with sudo) builds a root-owned copy with uv, using root's own Python and cache:
/opt/ahost/python/ uv-managed CPython (UV_PYTHON_INSTALL_DIR)
/opt/ahost/venv/ runtime venv, installed --frozen from uv.lock
/var/cache/ahost-uv/ root's uv cache (UV_CACHE_DIR), never a user's cache
/usr/local/sbin/ahost-helper root-owned launcher → /opt/ahost/venv/bin/python -m ahost.helper
ahost.service runs /opt/ahost/venv/bin/ahost serve as the ahost user. To upgrade, review the diff and re-run the install.
They're good, but Docker-first: they want to own ports 80/443 with their own proxy, and every app has to become an image. AHost is for servers where apps already run as plain processes, and it adopts them one at a time next to an existing nginx.







