Chapter 02.2
Custom stacks
A stack is your own Docker Compose project, cloned from a git repository and built on the box. You decide what runs; the platform decides where its ports go and which domains reach it. Most of this chapter is one contract — the handful of things your compose file must and must not do.
Creating one
A stack is created from three things, and nothing else:
- Slug
- Lowercase letters, digits and hyphens. This is the stack's name — it has no domain — and it is baked into the compose project name, the host path and every volume label. It cannot be changed: renaming a stack is deleting it and creating it again.
- Repository URL
- SSH form:
git@git.example.com:group/project.git. The repository must have a compose file at its root, namedcompose.yamlordocker-compose.yml. Submodules are cloned too. - Branch
- The branch every deploy pulls from. There is no per-deploy branch or tag picker.
Creating a stack clones nothing. It generates an SSH key pair and shows you the public half — the next step is yours.
The deploy key
The panel authenticates to your git host with a key it generated for this stack alone. Register the public key on your repository as a read-only deploy key, then press Fetch source. Fetching before you register it fails with an authentication error and changes nothing; the button stays where it is.
Three things sit next to the key on the stack page:
- Git host key
- Recorded on the first successful fetch, and displayed as a fingerprint. Check it against the fingerprint your git host publishes. Every fetch after that is strict — if the host ever presents a different key, the operation fails loudly and nothing is fetched, rather than quietly trusting the new one. If the host key legitimately changed, use Re-scan host key; it shows you the old and new fingerprints.
- Rotate key
- Generates a new pair and discards the old one immediately. Fetching fails until you register the new public key. This is the fix if a key is ever exposed.
- Download private key
- Available if you need the same key elsewhere. A downloaded key cannot be un-leaked — rotate it if in doubt.
The compose contract
Your repository is treated as untrusted input. Not because of you — because a compose file collects copy-pasted lines from tutorials, and several perfectly normal-looking ones would take the whole box down with them. The panel therefore validates the compose file on every deploy, not just the first, and refuses to start anything if it fails. A refusal names the service and the construct, and leaves the previous deployment running.
The ports your services need
You declare which ports you need; the panel decides what they are. These are the stack's allocated ports, and there is no field for them anywhere in the panel — the requirement is read out of your compose file every time, so the two can never drift. Opening a port on the server itself is a different thing entirely, and it is at the end of this chapter.
Every ports: entry must be written in exactly this shape:
ports:
- "${NIMBUS_BIND:-127.0.0.1}:${NIMBUS_PORT_WEB:-8080}:80"
Three parts, and each is doing a job:
${NIMBUS_BIND}- The address the port binds to on the box. The panel sets it to the Docker bridge address — reachable from Caddy, which is itself a container, and reachable from nothing on the internet. A literal
127.0.0.1is rejected: that would be the host's loopback, which Caddy cannot reach, and your domain would be accepted and then serve nothing. ${NIMBUS_PORT_<NAME>}- The host port.
<NAME>is yours to choose —WEB,API,ADMIN. The panel allocates one free host port per distinct name out of a reserved range and injects the values when it runs compose. Add a new published service and it simply gets a new allocation on the next deploy. - The
:-defaults - Write them. They never fire under the panel, because the variables are set — but they are what keeps the repository runnable on your laptop with a plain
docker compose up. Prefer a high fallback::-80needs privileges locally and is usually taken.
Both the short and the long syntax are accepted. What matters is the shape:
| You write | Verdict |
|---|---|
| "${NIMBUS_BIND:-127.0.0.1}:${NIMBUS_PORT_WEB:-8080}:80" | Accepted — allocates NIMBUS_PORT_WEB |
| "${NIMBUS_BIND}:${NIMBUS_PORT_WEB}:80" | Accepted — defaults are optional, but write them anyway |
| long form: published: "${NIMBUS_PORT_WEB}" host_ip: "${NIMBUS_BIND:-127.0.0.1}" |
Accepted |
| "8081:80" | Refused — a fixed host port bypasses allocation and will collide |
| "${NIMBUS_BIND:-127.0.0.1}:8081:80" | Refused — right bind, fixed port |
| "127.0.0.1:${NIMBUS_PORT_WEB}:80" | Refused — literal loopback. Caddy is a container; it could never reach this |
| "${NIMBUS_PORT_API}:3000" | Refused — no bind address, so it publishes on every interface |
| "0.0.0.0:${NIMBUS_PORT_API}:3000" | Refused — the same, stated out loud |
A service other containers in your stack talk to does not need a ports: entry at all — inside the project they reach each other by service name on the project's own network. Give one only to the services you intend to put a domain in front of. Every allocated port is one more thing at the edge of the box.
What a compose file may not contain
Each of these is refused with a message naming the service and the key. None of them is a matter of taste — each one either escapes the boundary that keeps stacks apart, or defeats the edge that keeps the box safe.
| Construct | Why |
|---|---|
| container_name: | A fixed global name collides the moment a second stack does the same. The panel names containers. |
| name: (top level) | The panel owns the project name (stk-<slug>), so yours would silently not take effect. Being told beats a confusing no-op. |
| volumes: { x: { external: true } } | Reaches into another project's data — and state outside your project's label is state no backup can find. |
| network_mode: host pid / ipc / userns_mode: host |
Leaves the project's namespaces. A host-network service binds ports that no ports: entry ever shows, which makes every rule above unenforceable. |
| network_mode: container:<other> | Joins a container outside your project. |
| privileged: true cap_add: · devices: · security_opt: |
Root on the box, or a short path to it. |
A bind mount from outside your own application/ |
Reads or writes the host. /var/run/docker.sock in particular is root-equivalent. |
| - "${HOME}/stuff:/y" | A variable in a volume source can hide any host path, so any variable there is refused. |
Named volumes, build contexts inside the repository, healthchecks, depends_on, your own networks, restart policies — all ordinary, all fine. The list above is the whole restriction.
Reaching MySQL and Redis
The platform's shared MySQL and Redis are available to your containers under two names the panel injects at deploy time:
mysql.nimbus:3306
redis.nimbus:6379
Use the names. Put no IP address in your repository, and do not reach for host.docker.internal — on a stack's own compose network it resolves to a gateway where nothing is listening. The panel resolves the right address on every deploy and writes it into an override file of its own, outside your clone.
Use the stack's Databases tab exactly as a catalog site would. The Host displayed on that page is the platform-internal name, because that is what phpMyAdmin uses — but Apply to .env knows this is a stack and writes DB_HOST=mysql.nimbus, the name your containers can actually resolve.
Where your files live, and what survives
/var/www/stacks/<slug>/
application/ the clone — git's entire world
.env your environment, symlinked into application/
.nimbus/override.yml panel-owned, never inside the clone
The important property is the same one catalog sites get: durable state lives outside the clone. A git clean -xdf, a reset --hard, or a .gitignore that turns out to be wrong cannot reach your .env, and cannot reach the panel's override either.
The .env is a symlink into the project directory precisely so both of the usual mechanisms find it: compose auto-loads .env from the project directory, and env_file: .env in a service resolves to the same file. Edit it from the stack's Environment tab. The panel never writes into it uninvited and never injects a managed block — it is your file.
Your named volumes are real Docker volumes, prefixed stk-<slug>_. They survive redeploys, rebuilds and restarts. They do not survive deleting the stack.
Deploying
There is no deploy-on-push for stacks — the in-container runner from chapter 10 deliberately cannot reach Docker, and a stack deploy builds images. You press a button.
- Deploy
git pullon your branch, validate the compose file, thencompose up -d --build. Compose rebuilds only what changed. Takes new code.- Rebuild
- The same without the pull, and forcing a rebuild and recreate of the checkout that is already there. Takes no new code. This is the one to use after editing
.env, or when an upstream base image has moved. - Start · Stop · Restart
- Move the project's existing containers. No build, no pull.
The full output — pull, validation, build log — appears on the stack page while it runs, and the deployed commit is shown when it finishes. Only one deploy runs at a time; pressing Deploy while one is in flight is refused rather than queued.
Domains
A stack has no domain until you give it one, and it never has a primary. You add rows: each row points one domain at one of the stack's allocated ports.
- Rows can only be added after the first successful deploy — before that there are no allocated ports to point at.
- A row exists ⇒ that domain is served. There is no publish toggle. Adding the row publishes; removing it unpublishes. The stack keeps running either way.
- A stack with no rows at all is a normal, healthy state: it runs, it is reachable on the box, it is served to nobody.
- Domains are unique across the whole box — you cannot claim one already used by another site, an alias, or another stack's row.
- DNS must point at the box before the certificate can be issued, exactly as in chapter 07.
Whatever your containers run, they are reached through the edge and never directly, so what they see as the client address is the proxy and what they see as the scheme is http. Configure trusted proxies in your framework — the setting, and why trusting every proxy is the right answer on this box, are in chapter 07. It applies here exactly as it does to a catalog site.
Locking works as it does everywhere else (chapter 08), with one difference worth knowing: one lock covers every one of a stack's domains. There is no per-row lock, because every row serves the same application — locking one and leaving another open would be a false sense of safety.
Publishing a port for traffic that is not a website
Everything above is a website: a domain, a certificate, Caddy in front. Some things are not. If your stack has to be reached by something else — git clone ssh://… from a Gitea stack is the case this exists for — an administrator can open a numbered port on the server and hand the raw connection straight to one of your services. The panel page is Ports, in the menu just after Routes.
It applies to stacks only, and only an administrator sees the page. A catalog site is one container serving a website, so it has nothing to publish this way; if you are a site owner, this is something to ask for rather than do.
Routes and Ports answer the same question for different traffic. If what you are publishing is a website, it is a Route.
| A Route | A Port | |
|---|---|---|
| What it publishes | A domain — git.example.com |
A numbered port on the server — 2222 |
| What is in the path | Caddy, which terminates TLS and dials your container | Nothing. The connection reaches the container as it arrived |
| HTTPS | A certificate, issued and renewed for you | None. Nothing is encrypted unless the service does it itself |
| The IP allowlist | Applies — locking the stack shuts every domain | Does not apply, and cannot |
| Who adds one | You, as rows on the stack | An administrator, on the Ports page |
Locking is a rule in the edge proxy and works on HTTP requests (chapter 08). The proxy is not in this path, so the allowlist cannot cover a raw port at all — a stack you have locked is still reachable on every port published for it, from anywhere. Whatever listens on the container port is the only access control there is.
Publish a port for a protocol that protects itself, as SSH does. A plain HTTP service published this way is served without a certificate and to everybody.
The three things a port is made of
- Service
- The service name out of your own
compose.yaml—gitea,web, whatever you called it. A name the stack does not have is refused at once, and the panel lists the names it does have. - Container port
- What that service listens on inside its container:
22for git over SSH. - Host port
- The number to open on the server, taken from a reserved range — 2200–2299 by default. The server's own SSH is on 22 and cannot be taken, which is why a Gitea stack advertises 2222 instead and its clone URLs carry that number. Anyone cloning needs it.
A port is attached when a container is created, so the panel recreates the service you published it for. That is a few seconds of downtime for that service, nothing for the rest of the stack, and no effect on any data. Removing a port does the same.
Allocated and published are not the same list
The stack's own page shows its published ports in a card next to Allocated ports. They look alike and mean opposite things:
- Allocated ports are the
${NIMBUS_PORT_*}numbers from the compose contract above. The panel picks them, and only the platform's own web server can reach them. - Published ports are picked by an administrator, and anyone who can reach the server can reach them.
The stack page only shows them. Adding and removing is on the Ports page.
Git over SSH, end to end
Two halves, and both are needed: the panel opens the door, and Gitea decides whether to offer ssh:// URLs at all.
- On the Ports page, publish host port 2222 to container port 22 on the
giteaservice. - In your compose file, switch Gitea's own SSH on with
DISABLE_SSH: "false", set the SSH port it advertises to2222, and press Deploy.
Clone URLs then read ssh://git@git.example.com:2222/org/repo.git. Leave the first half out and nothing is listening; leave the second out and Gitea shows no ssh:// URL to copy.
A compose file that works here
Nothing exotic. Note what is not there: no container_name, no top-level name, no fixed host port, and no IP address anywhere.
services:
web:
build: .
restart: unless-stopped
ports:
- "${NIMBUS_BIND:-127.0.0.1}:${NIMBUS_PORT_WEB:-8080}:8000"
env_file: .env
depends_on:
- db
worker:
build: .
restart: unless-stopped
command: ["python", "worker.py"]
env_file: .env
depends_on:
- db
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
web is published because a domain will point at it. worker and db are not — web reaches db as db:5432 on the project's own network. POSTGRES_PASSWORD comes from the .env you edit in the panel. And the whole thing still runs on your laptop with docker compose up, because of the :-8080 default.
Deleting a stack
Delete is a full destroy, and it is deliberately thorough: every container, network, volume and locally-built image the project owns, the whole /var/www/stacks/<slug> directory, the deploy key, the routing rows, the provisioned database and its MySQL user, and the port allocations.
Your named volumes go with it. If there is data in that Postgres volume, get it out first. Pressing Delete stack opens a dialog that asks you to type the slug and refuses anything else, and that typed confirmation is the only safety net there is.
Destroy does not need your compose file to be valid, or even present — it finds everything by the project label. A stack with a half-finished clone or a compose file you broke while debugging still deletes cleanly.