Castle ships with a curated catalog of self-hosted apps that install in
one click. Each entry is a typed template; the installer pulls the image(s),
generates labels and ports, registers a *.local alias, and (when a backend
is added) generates an nginx vhost.
| ID | App | Image | Multi-service |
|---|---|---|---|
ollama |
Ollama | ollama/ollama:latest |
no (GPU) |
tangle |
Tangle (git) | wess/tangle:main |
yes (+ pg) |
stohr |
Stohr (storage) | wess/stohr:main |
yes (+ pg) |
jellyfin |
Jellyfin | jellyfin/jellyfin:latest |
no |
vaultwarden |
Vaultwarden | vaultwarden/server:latest |
no |
uptimekuma |
Uptime Kuma | louislam/uptime-kuma:1 |
no |
homeassistant |
Home Assistant | homeassistant/home-assistant:stable |
no |
adguard |
AdGuard Home | adguard/adguardhome:latest |
no |
n8n |
n8n | n8nio/n8n:latest |
no |
paperless |
Paperless-ngx | ghcr.io/paperless-ngx/paperless-ngx:latest |
yes (+ pg) |
The full source is packages/apps/src/catalog.ts.
From the Apps page in the web UI:
- Click the card.
- Enter an instance name — this becomes the container name, the volume
prefix, and the
*.localhostname. - Click Install.
Behind the scenes:
- Each service in the template is pulled in dependency order.
- Containers are created with labels:
castle.app=<appId>castle.instance=<name>castle.service=<service-key>castle.role=primary|db|cache|worker
- Ports are deterministically hashed off
(instance, container-port)and bound to0.0.0.0. - Volumes are named
castle_<instance>_<service>_<volume-name>. - The primary service container is named
<instance>; non-primary services are named<instance>-<service-key>. - The hostname
<instance>.localis registered via mDNS.
After install you'll see a toast with the canonical URL, e.g.
http://my-jellyfin.local:38291.
AppTemplate (in packages/apps/src/types.ts):
type AppTemplate = {
id: string // catalog id, e.g. "ollama"
name: string // display name
description: string
category: string // "AI" | "Media" | ...
icon: string // lucide-react icon name
docs?: string // upstream docs URL
multi: boolean // surfaces in UI; informational
services: AppService[]
}
type AppService = {
key: string // service id within the app, e.g. "db" or "app"
role: "primary" | "db" | "cache" | "worker"
image: string // docker reference
ports?: AppPort[] // exposed ports; first or `primary: true` becomes the URL
env?: Record<string, string> // values run through variable expansion
volumes?: AppVolume[] // named volumes
cmd?: string[]
dependsOn?: string[] // other service keys; controls install order
generateSecrets?: string[] // names of secrets to generate (32 hex bytes)
gpu?: boolean // pass --gpus all (NVIDIA via DeviceRequests)
}Inside env values you can reference:
${INSTANCE}— user-supplied instance name${SECRET:KEY}— substitutes a generated secret (must be listed ingenerateSecretsof at least one service in the template)${INPUT:KEY}— substitutes a user-supplied prompt input
Example from tangle:
env: {
DATABASE_URL: "postgres://postgres:${SECRET:POSTGRES_PASSWORD}@${INSTANCE}-db:5432/tangle",
SECRET: "${SECRET:SECRET}",
APP_URL: "http://${INSTANCE}.local",
},Secrets are shared across services for the same install — db declares
POSTGRES_PASSWORD in generateSecrets, and app references it in its env.
When multi: true, the installer:
- Sorts services by
dependsOn(topological). - Pulls + creates + starts each in order.
- Sets up Docker DNS-friendly container names so the
appservice can reachdbas<instance>-db:5432.
Example: a Tangle install named tangle1 produces:
- container
tangle1-db(postgres) - container
tangle1(Tangle's primary service) - volumes
castle_tangle1_db_pgdata,castle_tangle1_app_repos,castle_tangle1_app_blobs - alias
tangle1.local
A service with gpu: true will be created with Docker DeviceRequests
equivalent to --gpus all:
{ "Driver": "nvidia", "Count": -1, "Capabilities": [["gpu"]] }For this to work you need the NVIDIA driver + nvidia-container-toolkit on the host. See GPU.
The Ollama catalog entry has gpu: true so a fresh install lands on a
GPU-accelerated container by default.
From the Apps page, click the trash icon on an installed row. Castle:
- Stops and removes every container labeled with that instance.
- Removes the
*.localalias. - Reloads mDNS.
Volumes are not removed. If you want to wipe data:
docker volume ls | grep castle_<instance>_
docker volume rm castle_<instance>_<service>_<vol>- Add an entry to
packages/apps/src/catalog.ts. - Pick an icon name from
lucide-reactand add it to theICONSmap inapps/web/src/pages/apps/index.tsxif not already there. - Use
${INSTANCE}-<service-key>for inter-service hostnames so Docker DNS resolves them inside the user-defined bridge. - If the upstream image isn't on Docker Hub, prefix the ref:
ghcr.io/...,quay.io/..., etc. - Deploy.
- The default network for installed apps is Docker's default bridge, which means inter-container DNS works for app-internal services (since they share the bridge) but not across separate installs. Use a user-defined network in your template if you need cross-app DNS.
- There's no upgrade flow yet — you uninstall and reinstall. Volumes persist, so data survives.
- No health checks beyond what the image bakes in. Castle doesn't yet poll containers and surface red dots in the UI.
- Apps page doesn't yet ask for required
prompts(e.g. an API key for an app that needs one); when this lands it'll surface a second step in the install modal.