- Shell 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bin | ||
| completions | ||
| examples | ||
| .gitignore | ||
| AGENTS.md | ||
| global.env.example | ||
| install.sh | ||
| manage-service.sh | ||
| ports.env.example | ||
| README.md | ||
| svc-lib.sh | ||
| svc.sh | ||
homelab-template
DISCLAIMER Bulk of this repo code is written with AI tools. By no means
svcshis not production ready or tested extensively. Main purpose was to build simple template for managing homelab docker compose files with clear and easy to use pattern, that gives just enough flexibility to navigate and control all services from single entry point svc.sh, separate config files from data (optionally) and version control config changes.
A minimal, terminal-first pattern for organizing Docker Compose services on a home
server: one directory per service or stack, shared values in global.env, every
host port in ports.env, and one wrapper (svc.sh) that injects the right env
files. No UI/daemon layer required — just SSH and compose files.
This repo is a template: it contains only the structure and reference
examples under examples/. Copy it, add your own services, and keep your real
values out of git.
Layout
.
├── svc.sh # entrypoint: svc.sh <action> <stack> [service]
├── svc-lib.sh # config-root resolution (env > recorded > in-repo)
├── bin/svc # `svc` wrapper (added to PATH by install.sh)
├── completions/ # bash/zsh/fish completions + svcd helper
├── install.sh # shell integration: PATH + completions + compose env
├── manage-service.sh # scaffold/remove a service directory
├── global.env.example # copy to <config root>/global.env; shared paths + identity
├── ports.env.example # copy to <config root>/ports.env; host ports, grouped by range
└── examples/ # reference examples (NOT managed by svc.sh)
├── example-single/ # one-service stack
├── example-stack/ # multi-service stack (depends_on)
└── example-site/ # reverse-proxied web site (web group)
The config root is the directory holding stacks/, global.env and
ports.env. It defaults to this checkout, so nothing changes if you keep code and
config together:
<config root>/
├── global.env # shared paths + identity
├── ports.env # host ports, grouped by range
└── stacks/ # your stacks — add your own (groups are optional)
├── immich/ # flat stack example → svc up immich
└── web/ # optional group example → svc up web/site-a
└── site-a/
Your instance will look the same, plus one directory per stack under stacks/,
each with a real .env next to its compose.yml.
Groups are optional. They exist only to separate purposes (e.g. web = hosted
sites, homelab = plain services). If you don't need that separation, put stacks
directly under stacks/ (e.g. stacks/immich/) and call
./svc.sh up immich — everything works the same, you just omit the group.
Examples live in examples/, not stacks/, so template examples and your
real stacks can never collide (and a git pull can never touch your stacks).
They are reference material only; to try one, copy it into the config root first:
cp -r examples/example-single "<config root>/stacks/" # then: svc up example-single
Scripts and config in different places
The checkout is just code and holds no per-host values; the config root is just
data. They can live anywhere, so you can keep one svcsh checkout (e.g.
~/ops/svcsh) and drive compose files elsewhere (e.g. /srv/services), or
work against several config roots from the same checkout.
The config root is resolved in this order, first match wins:
$SVC_CONFIG_DIR— per-shell or per-invocation override; the way to work on two roots at once.~/.config/svcsh/config($XDG_CONFIG_HOMErespected) — recorded once, so it survives re-cloning or updating the scripts.- the checkout itself, when it still has a
stacks/directory (legacy layout — nothing to configure). - otherwise nothing is found:
svc.shasks once on a terminal and records the answer, and errors out when stdin/stdout is not a terminal (CI, cron, completion).
./install.sh --config-dir /srv/services # record it non-interactively
./svc.sh --config-root # print what is resolved
SVC_CONFIG_DIR=/srv/services ./svc.sh up immich # one-off override
svcd with no argument cd's to the config root, and the optional
COMPOSE_ENV_FILES hook from install.sh activates inside the config root
rather than the checkout — that is where global.env, ports.env and the stacks
actually are.
Naming: stacks, groups, services
Every invocation has three words — action, stack, and optionally a service:
svc <action> <stack> [service]
- Action —
up stop down restart pull logs config ps path edit. - Stack — one token. It is a directory name under
stacks/, either flat (immich) or grouped (web/site-a). The slash is part of the stack's name — it mirrors the directory layout, not a path separator. - Service — a compose service inside the stack, space-separated, exactly
like
docker compose up <service>.
Rule of thumb: slash = directory structure, space = compose argument.
| You type | What runs |
|---|---|
svc up immich |
the whole flat stack |
svc up web/site-a |
the whole grouped stack |
svc up homelab/media sonarr |
only sonarr + its depends_on |
svc up homelab/media/radarr |
never valid — radarr is a service, not a stack |
Tab-completion follows the same grammar: after the action it completes stack names (one token, slash inside), after the stack it completes the stack's services (space-separated). Groups never nest deeper than one level.
Rules of the pattern
- One compose project per directory under
stacks/— the directory name is the stack name you pass tosvc.sh. A stack may live directly understacks/(e.g.stacks/immich/→svc.sh up immich) or under an optional group directory (e.g.stacks/web/site-a/→svc.sh up web/site-a) that separates purposes like hosted sites from plain services. The compose file may be namedcompose.yml,compose.yaml,docker-compose.ymlordocker-compose.yaml— all four are supported (checked in that order). - Services that depend on each other share a stack directory (app + db,
downloader + indexer + media server, ...) so
depends_onworks and they start together. Independent services get their own directory. compose.ymlnever hardcodes host ports or shared paths — it references${VARS}resolved from the central files.global.env— shared, machine-specific values (drive paths, PUID/PGID, TZ). Each group gets its own data root (HOMELAB_SERVICE_DATA_PATH,WEB_SERVICE_DATA_PATH). It also carries the scaffold defaults used by themanage-service.shwizard:SVC_NETWORK,SVC_DEFAULT_GROUP,SVC_TUNNEL_DOMAIN. Not committed: copy fromglobal.env.example.ports.env— the single source of truth for host ports, grouped into fixed numeric ranges per category. New services take a free number in the matching range, so collisions are visible at a glance. Instance-specific: copy fromports.env.example, never committed (see Secrets).- Per-service
.env— only secrets/config unique to that service. Never ports or shared paths. Not committed: copy from the service's.env.example. - Use
svc/svc.shas the entrypoint. Compose does not search parent directories, so plaindocker compose uponly works inside a stack when the compose-env hook frominstall.shis active (answer yes to the prompt or pass--compose-env) — it exportsCOMPOSE_ENV_FILESwhile your shell is inside the config root (the directory holdingglobal.envandports.env).
Env layering
svc.sh invokes compose with all three layers (paths are absolute, so this works
from any depth; files that don't exist are skipped):
docker compose --env-file <config root>/global.env --env-file <config root>/ports.env --env-file .env ...
Later files override earlier ones, so precedence is:
shell > service .env > ports.env > global.env.
Compose expands ${VAR} inside env files, which is why global.env can derive its
paths from drive roots:
SSD_PATH=/mnt/ssd
HOMELAB_SERVICE_DATA_PATH=${SSD_PATH}/services/homelab
Requires a recent Docker Compose v2 (multiple --env-file and COMPOSE_ENV_FILES
need >= 2.24).
Port ranges
Keep ports.env grouped so the number tells you what it is:
2800s web sites (group: web, direct ports)
3000s web UIs / dashboards
3100s media
3200s apps / automation
3400s documents
3500s productivity
3600s files / storage
3700s infrastructure
Only the host side uses a variable:
ports:
- "${MYAPP_PORT}:8080"
Ports that must keep their real default (DNS 53, VPN 51820, reverse proxy 80/443,
torrent peer port 6881, ...) are documented exceptions in ports.env.
Usage
cp -r examples/example-single stacks/ # try an example
./svc.sh up example-single
./svc.sh up homelab/media sonarr # whole stack / one service
./svc.sh up homelab/media # whole stack
./svc.sh config web/site-a # print resolved config, no side effects
./svc.sh logs homelab/media sonarr
./svc.sh stop homelab/media sonarr # stop one service (down is stack-wide)
./svc.sh down homelab/media
./svc.sh pull homelab/media sonarr # pull image(s) with the env layer loaded
./svc.sh path homelab/media sonarr # host data path(s) for app (first is primary)
./svc.sh edit web/site-a # open compose.yml at the site section
./manage-service.sh web mysite # scaffold a new web site (group)
./manage-service.sh immich # scaffold a stack without a group
./manage-service.sh homelab myapp # scaffold into the homelab group
Run in a terminal, manage-service.sh add (or create) becomes an interactive
wizard: it asks for the image, a port mapping host:container (the next free
host port in the range is suggested; none = internal only), extra env vars,
which network to join, and whether a Cloudflare tunnel ingress is needed. It
then writes a real compose.yml (port var wired to ports.env, data volume,
network from ${SVC_NETWORK}), the stack's .env + .env.example, updates
ports.env, and prints the tunnel reminder
(myapp.ponado.lt -> http://myapp:8080). Wizard defaults come from
global.env: SVC_NETWORK, SVC_DEFAULT_GROUP, SVC_TUNNEL_DOMAIN. When
stdin is not a terminal (scripts, CI) it falls back to the plain alpine
scaffold.
install.sh puts the svc command on PATH and wires tab-completion
(action -> stack -> service) plus the svcd helper that cd's straight to a
config or data directory (needs jq). It records the config root (asking once on
a terminal; --config-dir DIR to skip the prompt), and it asks whether to also
export COMPOSE_ENV_FILES while your shell is inside the config root, so plain
docker compose up can keep working from any stack directory (that mode adds a
small shell hook: bash PROMPT_COMMAND / zsh chpwd / fish PWD event). bash,
zsh and fish are supported:
./install.sh # detect shell, ask about compose-env + config root
./install.sh fish # or force bash / zsh / fish
./install.sh --compose-env # enable the hook without asking (for scripts)
./install.sh --no-compose-env # ensure it stays off
./install.sh --config-dir /srv/services # record where stacks/ + env files live
./install.sh --remove
svc up homelab/media # `svc` works from anywhere after install.sh
svcd # cd to the config root (where stacks/ + env files live)
svcd homelab/media # cd to the stack's config dir
svcd homelab/media sonarr # cd to sonarr's primary data dir
Without installing, use ./svc.sh ... from the repo. Manual completion setup:
source completions/svc.bash (bash), source completions/svc.zsh (zsh); fish
files live under completions/fish/.
New server setup
Keep code and config together (the default):
git clone <this repo> ~/services/homelab && cd ~/services/homelab
cp global.env.example global.env # set drive paths, PUID/PGID, TZ
cp ports.env.example ports.env # assign host ports per range
cp -r examples/example-single stacks/ # bring over an example to try
cp stacks/example-single/.env.example stacks/example-single/.env
docker network create homelab-network # external network stacks join
./install.sh # optional: `svc` on PATH + completions
./svc.sh up example-single
Or keep the scripts in one place and the compose files in another:
git clone <this repo> ~/ops/svcsh
mkdir -p /srv/services
cp ~/ops/svcsh/global.env.example /srv/services/global.env
cp ~/ops/svcsh/ports.env.example /srv/services/ports.env
mkdir -p /srv/services/stacks
~/ops/svcsh/install.sh --config-dir /srv/services # records the root once
~/ops/svcsh/manage-service.sh immich # scaffolds into /srv/services
~/ops/svcsh/svc.sh up immich # works from anywhere
Hardware-specific lines in compose files (/dev/dri, /opt/vc/lib,
/etc/sane.d/..., extra external networks) are expected to be tailored per host.
Secrets
.gitignore excludes global.env, ports.env and every .env; only
compose.yml, scripts, and *.env.example belong in git. To offer this as a
starting point for others, mark the repo as a template on GitHub (Settings ->
Template repository) so copies don't share history.
AI assistance
Everything in this repository was created with AI assistance (OpenCode) at the
direction of a human maintainer: the scripts (svc.sh, svc-lib.sh,
manage-service.sh, install.sh), the completions/ files and this README. The compose
examples and layout are derived from a real homelab. Review before relying on it.