- Go 80.7%
- HTML 8.8%
- CSS 5.9%
- JavaScript 4.4%
- Dockerfile 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
/ image (push) Successful in 39s
A sign-out at the provider reached nullContainer only at the next re-check, up to NULLCONTAINER_SESSION_RECHECK later, and not at all without a refresh token. The provider can now say so at once: it posts a signed logout token to /oidc/backchannel-logout, and the sessions it names end there and then. The token is verified as section 2.6 of OpenID Connect Back-Channel Logout asks: signature, issuer, audience and expiry through the same go-oidc verifier the login uses, then the logout event and the absence of a nonce -- which are what stop an ID token, which passes through the browser at every login, from being played as one -- a subject or a sid, and a jti not seen before. A sid names one browser's session at the provider, so it ends the logins that came through it and leaves the same person's others alone; the sid is now kept with each session from the ID token at login. A token with no sid ends every login the subject has, and a session from before sids were kept ends with any of them. Password sessions are never touched. Register <public URL>/oidc/backchannel-logout as the application's back-channel logout URI at the provider; nullAuth's preset does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| cmd/nullcontainer | ||
| deploy | ||
| internal | ||
| testdata | ||
| .dockerignore | ||
| .gitignore | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| logo.svg | ||
| README.md | ||
nullContainer
A web UI for the docker compose stacks on one host, behind an OIDC login or local accounts. It runs on any Docker host, TrueNAS SCALE included.
- Stacks are folders:
<stacks>/<name>/compose.yaml, plus an optional.env. Up, down, restart, pull, and pull & recreate are plaindocker composecommands. A stack behaves the same whether you run it from here or by hand in its folder. - An editor for each stack's compose file and
.env. It has line numbers, highlighting, YAML indentation and Ctrl-S. Every save is checked withdocker compose configfirst, and a file compose rejects is never written. The previous version is kept as a backup. - Containers, with CPU, memory and network use, healthchecks, ports, mounts, environment variable names, and start/stop/restart.
- Logs, per container or for a whole stack with its services merged. Recent lines show on the page, and new ones are added as they arrive.
- Images, volumes and networks. Each one shows which containers use it, and whether any of them is running. An image or volume no container uses can be deleted. One a container still uses cannot, even if that container is stopped: remove the container first. Networks are listed only.
- Image updates. For each container, nullContainer checks whether its image tag now points at something newer in the registry. One click pulls and recreates the stack.
- Everything else on the host is shown too.
- TrueNAS apps are shown read-only.
- Portainer and Dockge stacks show their compose file, and you can adopt them into the stacks folder.
- Projects started by hand are listed with their containers.
- Login, required: OIDC (limited to a group), local accounts from a password file, or both.
It works with JavaScript off, except following logs and operations live.
How it fits together
browser (OIDC) ──▶ nullContainer ──┬─ Engine API ─────────────▶ /var/run/docker.sock ──▶ dockerd
└─ docker compose (CLI) ───▶ /var/run/docker.sock
in <stacks>/<name>/
One container, holding the nullContainer binary, the docker CLI and its compose plugin.
- Reads go straight to the Docker Engine API over the socket: the container list, inspect, logs, stats, and registry digests.
- Changes to a stack run
docker compose --project-directory <stacks>/<name> -p <name> -f <file> …as a background operation with its own page. Closing the tab or a proxy timeout cannot kill a pull halfway through a recreate.
The stacks folder has to be at the same path on both sides
Compose resolves a stack's relative paths (./data:/data) against the
folder it reads, and then hands those paths to the host's Docker as they
are. So the stacks folder must be mounted at the same path in the container
as on the host, e.g. -v /srv/stacks:/srv/stacks. Otherwise every
relative bind mount would point somewhere else on the host. nullContainer
checks this at startup and refuses to start, naming the mount to fix, if
the paths differ.
Security
The Docker socket is root on the host. Anyone who can create a
container can mount / into it. So nullContainer treats a login here the
way you would treat SSH as root:
- Login is required. It refuses to start without
NULLCONTAINER_OIDC_ISSUERorNULLCONTAINER_PASSWORD_FILEunless it only listens on loopback. NULLCONTAINER_OIDC_ALLOWED_GROUPSis required with OIDC: it refuses to start without it.*lets in any account that can sign in at your provider, which makes every one of them root on the host, and logs a warning at every start.- Sessions live server-side with a fixed lifetime
(
NULLCONTAINER_SESSION_MAX_AGE, 12h by default); the cookie carries only a signed session ID. Group membership is re-checked everyNULLCONTAINER_SESSION_RECHECK(5m by default) by spending the refresh token the provider issued at login, so a disabled account, a revoked grant or a removed group ends the session within that interval. NarrowingNULLCONTAINER_OIDC_ALLOWED_GROUPSends the sessions it no longer allows at their next request, refresh token or not. A provider that can't be reached leaves sessions standing until it answers again. This needs a refresh token: nullContainer asks foroffline_accesswhen the provider advertises it; the provider has to grant it to the client. Without one, each login logs a warning and membership is checked only at login. - Signing out at the provider signs you out here at once, where the
provider does back-channel logout: register
https://<where you'll reach it>/oidc/backchannel-logoutas the application's back-channel logout URI, which nullAuth's nullContainer preset does. The provider posts a signed logout token there, and nullContainer ends the sessions that came through that browser's session at the provider, leaving your others alone. The provider has to be able to reach that URL. Without it, the re-check above is what notices. - Local accounts need no provider, alone or beside OIDC as the way in
when it is down.
NULLCONTAINER_PASSWORD_FILElists them, oneuser:bcrypt-hashper line ashtpasswd -Bwrites them (docker run --rm -i src.zerolatitude.dev/gquillery/nullcontainer -hash-passwordreads a password on stdin and prints its hash);/data/passwords, in the data folder, is the natural place. There are no groups: every account in the file is root on the host. The login page shows a password form, with a single sign-on button beside it when OIDC is set too. The file is re-read when it changes, so removing a line or changing a password ends that account's sessions at their next request, and deleting the file ends every account's. Ten failed logins from one address in 15 minutes block it for the rest of that window, however many arrive at once. An IPv6 address counts as its whole /64, and a /48 is blocked after a hundred; behind a reverse proxy every client shares the proxy's address, so the limit is global there. At most four passwords are checked at a time. Without OIDC there is no redirect URL to accept as aHost: list the proxy's name inNULLCONTAINER_ALLOWED_HOSTS.
Also:
- Cross-site requests that change anything are refused unless
Sec-Fetch-Site/Originshow they came from this UI. Every action is a POST. - A form has 30 seconds to arrive, so a client trickling a POST body, the login form's included, cannot hold connections open.
- DNS rebinding is blocked: only IP literals,
localhost, the redirect URL's host andNULLCONTAINER_ALLOWED_HOSTSare accepted asHost. - The Content-Security-Policy allows no inline script or style.
Container names, labels and log lines come from whatever the containers
print. They are always rendered as text, and live log lines are added
with
textContent. A test checks every template and rendered page. - Compose runs with a clean environment (PATH, HOME, DOCKER_HOST, and
little else), not nullContainer's own. Otherwise a compose file
containing
${NULLCONTAINER_OIDC_CLIENT_SECRET}would copy the client secret into a container. - Values are never shown, except in
.env. Container detail pages list environment variable names only. A Portainer stack'sstack.envis hidden until asked for. A stack's.envis shown in its editor, since editing it is the point, and it is saved with mode 0600. - Saving a file never writes through a link. A container that mounts its stack's folder could leave a symlink there; saves go through the folder only, and refuse a link that leads out of it.
- No shell anywhere. Commands are
exec'd with their arguments. Stack names are checked against compose's own project-name rule ([a-z0-9][a-z0-9_-]*) before any path or argument is built from one.
nullContainer will not take itself down. It finds its own container at startup, then refuses:
- Down, Restart and Delete on its own stack.
- Stop and Restart on its own container.
Up and Pull & recreate on its own stack are allowed, as the way to update it. They run through a throwaway helper container from nullContainer's own image, because compose recreating the container it runs in would be killed halfway, after stopping the old container and before starting the new one.
Stacks
/srv/stacks/
├── jellyfin/
│ ├── compose.yaml
│ ├── .env
│ └── config/ ← ./config in compose.yaml
├── paperless/
│ └── docker-compose.yml
└── .trash/ ← deleted stacks
-
One folder per stack, named after the compose project:
- lowercase letters, digits,
-and_ - compose's own file names:
compose.yaml,compose.yml,docker-compose.yamlordocker-compose.yml
Anything else in the folder is left alone, so you can keep a stack's data beside it, or keep the folder in git.
- lowercase letters, digits,
-
Saving writes the new file beside the old one and runs
docker compose config --quieton it there, so.envand relative paths resolve as they will for real. Only then is it renamed into place. The old version goes to<data>/backups/<stack>/. A rejected save shows compose's reason, and keeps what you typed in the editor. -
Saving does not apply anything. Save & up does both.
-
Deleting needs the stack to be down. It moves the folder, data included, to
<stacks>/.trash/<name>-<time>. Nothing is erased; move it back to restore it.
Other compose projects on the host
Any container compose created carries its project's name and folder in its labels. Projects whose folder is not in the stacks folder are listed under Elsewhere on this host:
- TrueNAS apps (
ix-*, or under/mnt/.ix-apps) are read-only. You can see their logs and stats, but TrueNAS's middleware owns them and would undo changes made from anywhere else. - Portainer stacks have their files under
/data/compose/<id>inside Portainer's container. nullContainer reads them from there through the same APIdocker cpuses. It never writes there, and no extra mount is needed. - Dockge stacks are folders in Dockge's stacks folder
(
/opt/stacks/<name>by default), which Dockge mounts at the same path as on the host. nullContainer reads them through Dockge's container, the same way as Portainer's, and never writes there either. A project counts as Dockge's when its folder sits directly in a folder Dockge's container bind-mounts, other than its/app/data. - External projects, started by hand or by another tool, are shown with their containers, which can be started and stopped.
Adopting a Portainer or Dockge stack
Adopt on a Portainer or Dockge stack's page runs as a background operation:
-
If the compose file bind-mounts paths inside the stack's folder (
./data), it stops the stack's containers, so their data is copied at rest. -
It copies the stack's whole folder into
<stacks>/<name>/, subfolders and large files included, with owners and permissions kept. The files Portainer keeps (compose file,stack.env, configs) come from Portainer's container. The data of each relative bind mount comes from the host: compose ran inside Portainer, so./datameant/data/compose/<id>/dataon the host, a different folder from Portainer's.stack.envis also copied as.env, so${VARIABLES}resolve as they did.A Dockge stack is copied the same way, from Dockge's container and from its own containers, which see the same folder. Its
.envis already where compose looks. If Dockge has aglobal.envin its stacks folder, which it passes to every stack before the stack's.env, its lines are put at the top of the new.env. The stack's own lines come after them and win, as they did under Dockge. -
It checks the result with
docker compose config. -
It recreates the stack from there under the same project name, so named volumes carry over.
The copy is made in a hidden folder, <stacks>/.<name>.adopting, and moved
into place only once it is complete. If the copy or the check fails, the
new folder is removed and the stopped containers are started again.
Before you adopt:
- Read the compose file on the stack's page first. After adopting,
compose runs it from inside nullContainer, with everything the Docker
socket allows. Portainer can restrict what its non-admin users' stacks
do (no bind mounts, no privileged containers); none of that comes along.
A line such as
env_file: /data/sessions.jsonwould hand nullContainer's own data folder to the stack. - Every container is recreated. With bind-mounted data, the outage lasts as long as the copy. The stacks folder needs room for the copy too.
- The old data stays where it was. Adopting copies, it never moves.
Remove
/data/compose/<id>on the host yourself once the adopted stack works. - Portainer still lists the stack afterwards, with Total control.
Portainer decides control from its own database, which still records the
stack as its own, and nullContainer never writes there. Leave the stack
alone in Portainer. Stop and Delete would take down a stack that
is now nullContainer's. Update the stack or a redeploy would be
worse: it would recreate the containers from Portainer's copy, with
./datapointing at the old data again. Turn off any Git auto-update or webhook on the stack. To be rid of the entry, retire Portainer: removing Portainer's container and data does not touch the stacks it deployed.
Dockge still lists the stack afterwards, as running. Its folder is
still in Dockge's stacks folder, and Dockge matches containers to stacks by
project name. Leave it alone in Dockge: Stop, Restart and
Delete would take down the adopted stack, and Update or Deploy
would recreate it from the old folder, with ./data pointing at the old
data again. Once the adopted stack works, remove the old folder on the
host by hand (rm -r /opt/stacks/<name>), not with Dockge's Delete. That
takes it off Dockge's list and leaves the containers running.
Stacks made of several compose files, or whose compose file is in a subfolder, are shown but have to be moved by hand.
Image updates
For every container, nullContainer compares the image the container was
created from with what its tag (nginx:1.27) names in the registry now.
The daemon asks the registry with its own credentials, so private
registries work wherever docker pull does.
The check runs every NULLCONTAINER_UPDATE_INTERVAL (6h by default), or on
demand from the Updates page. A container whose tag was pulled again but
which was not recreated also counts as an update: it still runs the old
image. Pull & recreate on a stack applies an update.
Results by image:
- Pinned: referenced by digest (
@sha256:…), so it never changes. - Local: built or loaded on the host, so there is no registry to ask.
- Unknown: the registry refused the check. The page says why, usually a private image without a login, or a Docker Hub rate limit.
To log in to a private registry, run
docker --config /data/docker login <registry> inside the container. The
login is kept in the data folder.
Installing
The image is src.zerolatitude.dev/gquillery/nullcontainer: :latest, or
:sha-<commit> to pin one. deploy/compose.yaml is
a complete compose file for it. Change the marked lines:
- the stacks path, on both sides of the mount
- the data path, which must be writable by the user the container runs as
- the OIDC settings, or
NULLCONTAINER_PASSWORD_FILEfor local accounts (see Security) - the Docker socket's group:
stat -c %g /var/run/docker.sockon the host
Then run docker compose up -d in its folder.
With OIDC, register nullContainer as a confidential client at your
provider, with the redirect URI https://<where you'll reach it>/oidc/callback.
The ID token or userinfo must carry the groups claim, and the client should
be allowed offline_access so sessions can be re-checked.
Put it behind a reverse proxy, with proxy buffering off for the live
views (/…/logs/stream, /jobs/…/stream). nullContainer sends
X-Accel-Buffering: no, which nginx honours.
On TrueNAS SCALE
- Create two datasets, one for the stacks and one for nullContainer's data, both writable by the apps user, 568.
- Go to Apps → Discover Apps → ⋮ → Install via YAML, paste
deploy/compose.yaml, and change the marked lines as above.
Installed this way, nullContainer is itself a TrueNAS app (ix-…), so it
shows itself read-only. Update it from TrueNAS's Apps page.
Settings
All are environment variables. See
deploy/nullcontainer.env.example for
every one, commented. Outside a container they can also be read from an
env file (-env-file, or /etc/nullcontainer/nullcontainer.env). A
NULLCONTAINER_* variable nullContainer does not know is an error, not
silently ignored.
| Variable | Default | Purpose |
|---|---|---|
NULLCONTAINER_STACKS_DIR |
(required) | the stacks folder; same path on host and in the container |
NULLCONTAINER_LISTEN |
127.0.0.1:8080 (0.0.0.0:8080 in the image) |
anything but loopback requires a login |
NULLCONTAINER_DATA_DIR |
/data |
session key and sessions, backups, update cache, registry logins |
NULLCONTAINER_DOCKER_SOCKET |
/var/run/docker.sock |
the daemon |
NULLCONTAINER_DOCKER_BIN |
docker |
the CLI that runs docker compose |
NULLCONTAINER_UPDATE_INTERVAL |
6h |
background update checks; 0 turns them off |
NULLCONTAINER_ALLOWED_HOSTS |
(empty) | extra Host names the UI is reached by |
NULLCONTAINER_OIDC_ISSUER |
(empty) | the provider's base URL |
NULLCONTAINER_OIDC_CLIENT_ID |
(empty) | |
NULLCONTAINER_OIDC_CLIENT_SECRET / _FILE |
(empty) | |
NULLCONTAINER_OIDC_REDIRECT_URL |
(empty) | must end in /oidc/callback |
NULLCONTAINER_OIDC_SCOPES |
openid profile email |
groups is added when groups are required |
NULLCONTAINER_OIDC_GROUP_CLAIM |
groups |
read from the ID token, falling back to userinfo |
NULLCONTAINER_OIDC_ALLOWED_GROUPS |
(required with OIDC) | comma-separated, or * for every account |
NULLCONTAINER_PASSWORD_FILE |
(empty) | local accounts, user:bcrypt-hash per line; re-read when it changes |
NULLCONTAINER_SESSION_MAX_AGE |
12h |
|
NULLCONTAINER_SESSION_RECHECK |
5m |
re-check sessions with the provider this often (needs a refresh token) |
The login uses the authorization code flow with PKCE and a nonce, and
checks at_hash, azp and the RFC 9207 iss parameter. Userinfo is only
trusted when its sub matches the ID token's. Logout goes through the
provider's end-session endpoint when it has one.
Development
Build, vet and test with the standard Go toolchain (go build ./...,
go vet ./..., go test ./...). None of it needs Docker:
internal/docker/dockertestis a fake Engine API on a real unix socket.internal/composetakes an injectable runner, so tests never run the CLI.
To look at the UI without Docker, run the fake daemon from testdata/ and
point nullContainer at it, with testdata/bin/docker.sh standing in for the
CLI:
go run ./testdata/fakedocker -socket /tmp/fakedocker.sock &
NULLCONTAINER_STACKS_DIR=$PWD/testdata/stacks \
NULLCONTAINER_DATA_DIR=/tmp/nullcontainer-data \
NULLCONTAINER_DOCKER_SOCKET=/tmp/fakedocker.sock \
NULLCONTAINER_DOCKER_BIN=$PWD/testdata/bin/docker.sh \
go run ./cmd/nullcontainer
Then open http://127.0.0.1:8080. You'll see three stacks, a TrueNAS app,
a Portainer stack and a Dockge stack to adopt, and a container compose did
not start. A file containing BROKEN is refused, the way the real
docker compose config refuses an invalid one.