A self-hosted app platform for one box. It installs and runs the apps itself — you pick something from a catalog, it pulls the images, creates the containers, and tells you where to log in.
The dashboard runs on port 8443.
On a clean Debian or Ubuntu box:
curl -fsSL https://get.podhouse.dev/install.sh | sudo bashIt downloads this repository, unpacks it to /opt/podhouse, then runs
install.sh — Docker, Node, the tree, this machine's own generated secrets,
the networks, and core + dashboard. About three minutes on a fresh VM.
It refuses to run over an existing install rather than half-upgrading one.
That command pipes a script from the internet into a root shell, which is worth being deliberate about. To read it first:
curl -fsSL https://get.podhouse.dev/install.sh -o hb.sh
less hb.sh && sudo bash hb.shget.podhouse.dev serves scripts/bootstrap.sh from this repository's main
branch, byte for byte, and counts installs anonymously (per day and country,
nothing stored per person — see infra/get-worker).
If get.podhouse.dev is unreachable
The same script, straight from GitHub:
curl -fsSL https://raw.githubusercontent.com/abeksis/Podhouse/main/scripts/bootstrap.sh | sudo bashHB_FETCH_ONLY=1 stops after unpacking. HB_REF=some-branch installs a
branch or tag. HB_TARBALL=<url> installs from anywhere else, for a network
with no route to GitHub.
Open the Updates tab and press the button. When a release is available the page says so, shows what changed, and updates the box — taking a backup first, and putting the previous version back on its own if the new one does not start.
From a terminal, the same thing:
sudo homebox self-update # or --check to look without doingReleases are git tags, and a box sits on one. homebox version prints both the version
and the tag, which is how you tell a box that is mid-update from one that is not.
Doing it by hand
The manual path still works and is the escape hatch when the dashboard is what broke:
cd /opt/podhouse && sudo git fetch --tags && sudo git checkout v0.2.0 && sudo bash install.shinstall.sh is idempotent: it repairs what is missing and never regenerates a secret
that already exists.
A box installed before self-update existed (0.1.0) has to make this one hop by hand — the mechanism cannot arrive through itself. After that, the button.
Separately, the dashboard's Updates tab handles a case a pinned version
does not protect you from. Every module here pins an exact tag on purpose
(pihole/pihole:2026.07.2, jc21/nginx-proxy-manager:2.15.1, …) so that
nothing jumps a major version unattended — but a pinned tag is not frozen
bytes. Publishers re-push the same tag when a base layer gets a CVE fix, so
the tag you pinned points at a new digest, and docker compose pull will
report "up to date" while your copy is months of security patches behind,
because compose only compares tags.
The Updates tab compares the digest of every running image against what its registry serves for that same tag, and lists the differences. Nothing is applied until you press the button. When you do, one service at a time:
- archive that module's
config/directory, - record the image id currently running — this is the rollback,
pull, thenup -d --no-depsfor that one service,- wait for it to report healthy; if it does not, re-tag the recorded image, recreate, and record the failure in the page's history.
When a genuinely newer version of an app exists, the tab shows an Upgrade button for it. A new version may migrate its database on first start, so that app is backed up before it switches. A new major version of a database (Postgres 14 to 17, say) is listed but never offered as a button: its data directory belongs to the version that wrote it, and moving between majors is a migration, not an image swap.
sudo bash /opt/podhouse/scripts/uninstall.shOr without anything on the box to run it from — it asks exactly the same way:
curl -fsSL https://get.podhouse.dev/uninstall.sh | sudo bashIt prints an inventory of exactly what will go — containers, networks, images
built here, and the size of each thing under /opt/podhouse — then asks you to
type remove. Options:
--yes |
skip the confirmation, for scripts |
--keep-data |
delete everything except data/ and backups/ |
--keep-images |
leave the images built here in place |
If the tree is already gone, run it straight from here:
curl -fsSL https://get.podhouse.dev/uninstall.sh | sudo bash -s -- --yesDocker is left installed. It was probably wanted anyway, and removing it would take any other containers on the machine with it.
Storage that is not under /opt/podhouse is never touched. If HB_DATA_DIR
or HB_MEDIA_ROOT points somewhere else — a NAS mount, say — the script says
so and leaves it completely alone. Uninstalling the dashboard must not mean
deleting the media. For the same reason the systemd mount units written by
scripts/mount-remote.sh are left in place; the script tells you where they
are if you want them gone.
.env, state/, backups/, data/ and every modules/*/config/ — the
generated passwords, the app databases and the media. They are produced on
each machine and belong to it. docs/HOST-CHANGES.md is also excluded: it
records what was done to one particular box, which is exactly the thing that
should not follow the code around.
/opt/podhouse
├── install.sh # clean Debian → running Podhouse
├── homebox # the CLI
├── .env # generated secrets, mode 600
├── modules/
│ ├── core/
│ │ ├── docker-compose.yml # services + x-homebox metadata
│ │ └── config/{npm,portainer} # this module's app config, in the module
│ ├── media/
│ │ ├── docker-compose.yml
│ │ ├── setup.sh # optional: seed files an image won't create
│ │ └── config/{radarr,sonarr,…}
│ └── … # one directory per module
├── dashboard/ # the web UI (node, no npm dependencies)
├── data/ # the shared pool: media, photos, books, downloads
├── scripts/
├── state/ # enabled list, activity log, UI prefs
└── docs/
A module is a directory. The compose file inside it carries both the services
and, in a top-level x-homebox: block, everything a person or the dashboard
needs to know about them:
x-homebox:
id: monitoring
title: "Monitoring"
tagline: "Know before your family tells you"
category: "system"
ram: "~180MB"
theme: { emoji: "📊", color: "#30d158" }
tips: [ "Start with a monitor for the dashboard itself…" ]
services:
uptime-kuma:
friendly_name: "Uptime Kuma"
color: "#5cdd8b"
port_map: 3001
first_login: "Create the admin account on the first visit."
services:
uptime-kuma:
image: louislam/uptime-kuma:2.5.3
container_name: uptime-kuma
…Compose ignores top-level x- keys, so the same file is both the deployment
unit and the catalog entry. There is nothing to keep in sync, and the app's
config directory lives in the module too — modules/<id>/config/<app>. Copy
the folder and you have copied the app.
homebox list # everything available, and what is running
homebox install monitoring # seed, pull, start, mark enabled
homebox info monitoring # what it is, where it is, how to log in
homebox secrets core # the generated passwords for one module
homebox status # every container Podhouse runs
homebox remove git --yes # stop and delete containers, keep the data
homebox remove git --yes --purge # ...and delete the data tooOr click Install — and Uninstall — on the Apps page. Same code path — the dashboard shells
out to docker compose, so the CLI and the UI can never disagree about what
is installed.
| Module | App | Default |
|---|---|---|
core |
Nginx Proxy Manager, Portainer | required |
dashboard |
Podhouse itself | required |
files |
File Browser | on |
monitoring |
Uptime Kuma | on |
dns |
AdGuard Home | on |
vpn |
WireGuard (wg-easy) | on |
cloud |
Nextcloud | on |
media |
Radarr, Sonarr, Prowlarr, Bazarr, qBittorrent, FlareSolverr | off |
jellyfin |
Jellyfin | off |
photos |
Immich | off |
passwords |
Vaultwarden | off |
bookmarks |
Linkding | off |
git |
Gitea | off |
automation |
n8n | off |
Adding another is a directory and a compose file — no registry, no rebuild,
no restart. homebox validate checks it; the dashboard picks it up on its
next refresh.
- One compose project per module (
homebox-<id>). A broken module can be rebuilt without touching anything else, and Docker's own project label is what maps a running container back to its module. Nothing is hand-maintained. - Containers are named after the app —
portainer,gitea,uptime-kuma. No prefix: this box runs nothing but Podhouse, and the module that owns a container comes from its compose project label, not from its name. - Secrets are generated once by
install.shinto.env(mode 600) and never regenerated — an encryption key that changes is data you cannot read. - Routing is Nginx Proxy Manager's job, configured in its own UI on port 81 — the same choice the original makes. Until you add a domain there, every app is reachable directly by port.
- The dashboard has no npm dependencies. Node builtins only, so the image builds without registry access and there is no dependency tree to audit for a process that holds a Docker socket.