Lightweight Docker image for Network UPS Tools (NUT) CGI web interface, built on Alpine Linux with security hardening and multi-architecture support.
- Alpine Linux - Minimal base image, well under 100 MB
- Multi-architecture - Native support for
linux/amd64andlinux/arm64 - Security hardened - Non-root user, pinned dependencies, vulnerability scanning
- Flexible UID support - Works with
--useroverride for volume mount permissions - Enhanced health checks - Tiered validation of web server and CGI functionality
- Automated updates - Renovate bot manages dependencies with semantic versioning
docker run -d \
--name nut-cgi \
-p 8000:80 \
-v /path/to/hosts.conf:/etc/nut/hosts.conf:ro \
--restart unless-stopped \
ghcr.io/owine/nut-cgi:latestSee docker-compose.yml for a complete example with security hardening.
# Create hosts.conf (see Configuration section)
docker compose up -dAccess the web interface at: http://localhost:8000
Create a hosts.conf file to define which UPS systems to monitor:
# Monitor local UPS
MONITOR myups@localhost "Living Room UPS"
# Monitor remote UPS systems
MONITOR serverups@192.168.1.100 "Server Rack UPS"
MONITOR officeups@192.168.1.101 "Office UPS"File location: /etc/nut/hosts.conf inside container
See hosts.conf.example for comprehensive examples and configuration tips.
For complete hosts.conf syntax, see NUT documentation.
The container supports two health check modes via the HEALTHCHECK_MODE environment variable:
basic(default): Validates infrastructure only (web server + CGI execution)strict: Validates infrastructure + UPS connectivity (fails if no UPS reachable)
environment:
- HEALTHCHECK_MODE=strict # Require UPS connectivity for healthy statusNUT ships upsset.cgi, an administrative interface that can change UPS settings and
issue commands such as shutdown. The image blocks it with a 403 by default.
To enable it, set ENABLE_UPSSET=true:
environment:
- ENABLE_UPSSET=trueOnly do this behind a reverse proxy that enforces authentication. upsset.cgi has no
authentication of its own, so anything that can reach it can power off your hardware.
The container sends a strict Content-Security-Policy by default:
default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'
This suits the stock interface, which loads no external resources.
The policy is not decorative. upsstats.cgi renders UPS variables (ups.model,
device.mfr, ups.status, …) into the page without HTML-escaping them, and this
image talks to upsd over plaintext NUT protocol. A hostile UPS, a spoofed upsd, or a
MITM on port 3493 can therefore inject markup into the status page. Because the
default policy allows no inline or external scripts, it blocks that injection from
executing. Keep script-src as restrictive as your deployment permits.
Override the policy with the CSP_POLICY environment variable when something
downstream of the container injects content into the page:
environment:
- CSP_POLICY: "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com"CSP_POLICY replaces the whole header rather than adding to it, so restate the
directives you want to keep. Two things catch people out:
- Naming a directive overrides
default-srcfor that type. Addingscript-src https://example.comwithout'self'blocks the page's own scripts. Repeat'self'in every directive you introduce. - A CDN often loads from one host and reports to another. The Cloudflare beacon
above is fetched from
static.cloudflareinsights.combut posts results tocloudflareinsights.com— the apex, nostatic.. It needs bothscript-srcandconnect-src; allowing only the script leaves the beacon loading but unable to report.
Set CSP_POLICY=none to send no CSP header at all. This is appropriate only when a
reverse proxy sets an equivalent policy in its place — given the unescaped rendering
above, a deployment with no CSP from either layer is genuinely exposed. Prefer none
over configuring both layers: two CSP headers are intersected by the browser rather
than one overriding the other, which is rarely what you want.
The container refuses to start if CSP_POLICY contains a double quote or newline,
since the value is interpolated into the lighttpd config.
The policy is sent by lighttpd, which cannot know about markup added after a
response leaves the container. Cloudflare Web Analytics, for example, injects its
beacon <script> at the edge — downstream of the origin, the reverse proxy, and the
tunnel — so default-src 'self' blocks it and the browser reports a CSP violation
for a script the container never served. CSP_POLICY exists for exactly this case.
Run as a specific user ID to match host filesystem permissions:
docker run -d \
--name nut-cgi \
--user 1001:1001 \
-p 8000:80 \
-v /path/to/hosts.conf:/etc/nut/hosts.conf:ro \
ghcr.io/owine/nut-cgi:latestFor production deployments, use security options from the example docker-compose.yml:
- Read-only root filesystem
- Drop all capabilities
- No new privileges
- tmpfs for writable locations
Use specific version tags for production (recommended):
# Pin to an exact version (recommended) -- replace with the current release
docker pull ghcr.io/owine/nut-cgi:vX.Y.Z
# Pin to a minor series (receives patch updates)
docker pull ghcr.io/owine/nut-cgi:vX.Y
# Pin to a major series (receives minor/patch updates)
docker pull ghcr.io/owine/nut-cgi:vXFind the current release on the Releases page or under Packages.
Available tags:
:vX.Y.Z- Specific semantic version (exact pinning; recommended for production):vX.Y- Latest patch in that minor series:vX- Latest minor in that major series:latest- Latest released version:main- Latest build from the main branch:sha-<commit>- Specific commit build (for debugging)
Tags are published only after a build passes its Trivy scan and functional tests.
Releases before v1.2.0 were published without the v prefix (e.g. 1.1.2).
- Stage 1 (builder): Compiles NUT from the upstream release tarball (SHA256-verified), building only the CGI programs, then strips debug symbols and the W3C validator badges from NUT's HTML templates
- Stage 2 (runtime): Alpine with lighttpd, curl, gd, and openssl, plus the compiled NUT CGI programs and libraries copied from the builder
Direct APK packages are explicitly version-pinned for reproducibility; the pinned versions
live in the Dockerfile and are kept current by Renovate. Transitive packages are resolved
by apk, which installs the repository's current build.
Runtime stage:
lighttpd- Lightweight web servercurl- Health check utilitygd- Graphics library used by NUT's CGI programsopenssl- Pinned explicitly because the base image can lag its own repository
Builder stage:
build-base,gd-dev,curl- Toolchain and headers for compiling NUT
nut-cgi itself is not an APK package here — it is compiled from source, pinned via
ARG NUT_VERSION in the Dockerfile.
Package versions are automatically updated by Renovate bot with semantic versioning.
Validation runs in tiers:
- Tier 1: Web server responding (HTTP success status)
- Tier 2: CGI execution working (non-empty response)
- Tier 3: Valid CGI infrastructure (no template or server errors)
- Tier 4: Valid HTTP response headers (
Content-Typepresent) - Tier 5: UPS connectivity — only when
HEALTHCHECK_MODE=strict
Tiers 1-4 validate the container itself and always run. Tier 5 is opt-in because an unreachable UPS is usually a network or UPS problem rather than a container problem, and failing the health check would restart a perfectly healthy web server.
Check intervals: 30s | Timeout: 10s | Start period: 15s | Retries: 3
# Clone repository
git clone https://github.com/owine/nut-cgi.git
cd nut-cgi
# Build multi-arch image
docker buildx build --platform linux/amd64,linux/arm64 -t nut-cgi:local .
# Test build locally
docker build -t nut-cgi:test .
docker run --rm -p 8000:80 nut-cgi:testGitHub Actions workflows:
- Lint (
lint.yml): Dockerfile (hadolint), shell scripts (shellcheck), YAML (yamllint), and GitHub Actions (actionlint) - Build (
build.yml): Multi-arch builds, publish to GHCR, then Trivy scanning and functional tests before promoting tags - Release Drafter (
release-drafter.yml): Keeps a draft GitHub Release up to date on every push tomain. Publishing the draft creates thevX.Y.Ztag and runsbuild.ymlto publish the versioned image
Trivy runs as the Security Scan job inside build.yml after a successful build on
main or a published release. It does not run on pull requests, and there is no scheduled
scan.
Renovate bot automatically creates PRs for:
- Alpine base image updates (auto-merge patch versions; minor versions need review,
since they require updating the APK pins and the Alpine branch in Renovate's
apkregistryUrlstogether) - Alpine package updates, looked up in the Alpine package index — all APK bumps share a single PR so they stay mutually buildable, and auto-merge once CI passes
- GitHub Actions updates (auto-merge minor/patch)
- NUT source version updates (manual review required)
Check logs for health check failures:
docker logs nut-cgiCommon issues:
lighttpd not responding- Web server crashed or config errornut-cgi not executing- CGI permissions or missing binarynut-cgi returned error content- Invalid hosts.conf configuration
If running into permission errors with volume mounts:
# Check container's UID/GID
docker exec nut-cgi id
# Run with matching UID/GID
docker run --user $(id -u):$(id -g) ...Ensure file is mounted correctly and readable:
# Verify mount
docker exec nut-cgi ls -la /etc/nut/hosts.conf
# Check file contents
docker exec nut-cgi cat /etc/nut/hosts.confContributions welcome! Please:
- Fork the repository
- Create a feature branch
- Make your changes
- Submit a pull request
All PRs must pass linting and build workflows.
MIT License - See LICENSE file for details.
- Original project: danielb7390/nut-cgi
- Network UPS Tools (NUT)
- Alpine Linux
- GitHub Releases - Release notes
- CHANGELOG.md - Version history up to v1.10.1
- SECURITY.md - Security policy and vulnerability reporting
- CLAUDE.md - Development guide and architectural decisions
- hosts.conf.example - Configuration examples
- GitHub: https://github.com/owine/nut-cgi
- Container Registry: https://github.com/owine/nut-cgi/pkgs/container/nut-cgi
- Issue Tracker: https://github.com/owine/nut-cgi/issues
- Security Advisories: https://github.com/owine/nut-cgi/security/advisories