No description
  • Go 80.7%
  • HTML 8.8%
  • CSS 5.9%
  • JavaScript 4.4%
  • Dockerfile 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Guillaume Quillery 864f7aae5f
All checks were successful
/ image (push) Successful in 39s
Take the provider's back-channel logout
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>
2026-10-08 21:03:41 +02:00
.forgejo/workflows Check the Go toolchain's checksum before unpacking it in CI 2026-10-08 15:34:47 +02:00
cmd/nullcontainer Check at startup that the data and stacks folders are writable 2026-10-03 21:13:26 +02:00
deploy Say which group to add when the Docker socket is not ours to open 2026-10-03 21:10:48 +02:00
internal Take the provider's back-channel logout 2026-10-08 21:03:41 +02:00
testdata Use neutral names and timezone in examples and tests 2026-10-03 20:42:16 +02:00
.dockerignore nullContainer: a web UI for docker compose stacks 2026-10-01 22:11:03 +02:00
.gitignore nullContainer: a web UI for docker compose stacks 2026-10-01 22:11:03 +02:00
Dockerfile Build the image without loading it into the runner's engine 2026-10-02 20:29:01 +02:00
go.mod Log in with local accounts, without OIDC 2026-10-03 20:08:08 +02:00
go.sum Log in with local accounts, without OIDC 2026-10-03 20:08:08 +02:00
logo.svg nullContainer: a web UI for docker compose stacks 2026-10-01 22:11:03 +02:00
README.md Take the provider's back-channel logout 2026-10-08 21:03:41 +02:00

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 plain docker compose commands. 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 with docker compose config first, 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_ISSUER or NULLCONTAINER_PASSWORD_FILE unless it only listens on loopback.
  • NULLCONTAINER_OIDC_ALLOWED_GROUPS is 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 every NULLCONTAINER_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. Narrowing NULLCONTAINER_OIDC_ALLOWED_GROUPS ends 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 for offline_access when 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-logout as 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_FILE lists them, one user:bcrypt-hash per line as htpasswd -B writes them (docker run --rm -i src.zerolatitude.dev/gquillery/nullcontainer -hash-password reads 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 a Host: list the proxy's name in NULLCONTAINER_ALLOWED_HOSTS.

Also:

  • Cross-site requests that change anything are refused unless Sec-Fetch-Site/Origin show 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 and NULLCONTAINER_ALLOWED_HOSTS are accepted as Host.
  • 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's stack.env is hidden until asked for. A stack's .env is 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.yaml or docker-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.

  • Saving writes the new file beside the old one and runs docker compose config --quiet on it there, so .env and 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 API docker cp uses. 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:

  1. 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.

  2. 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 ./data meant /data/compose/<id>/data on the host, a different folder from Portainer's. stack.env is 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 .env is already where compose looks. If Dockge has a global.env in 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.

  3. It checks the result with docker compose config.

  4. 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.json would 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 ./data pointing 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_FILE for local accounts (see Security)
  • the Docker socket's group: stat -c %g /var/run/docker.sock on 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

  1. Create two datasets, one for the stacks and one for nullContainer's data, both writable by the apps user, 568.
  2. 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/dockertest is a fake Engine API on a real unix socket.
  • internal/compose takes 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.