Browse Source

feat(deployment): add production container setup

main
Domagoj Zecevic 1 month ago
parent
commit
43bc80a5e4
  1. 28
      .ai/HANDOFF.md
  2. 24
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 9
      .dockerignore
  5. 61
      README.md
  6. 3
      compose.yaml
  7. 62
      scripts/test-production-deployment.sh

28
.ai/HANDOFF.md

@ -190,3 +190,31 @@ Each entry uses this exact structure. Omit fields marked as role-specific when t
| Next Role | none |
---
### T-006 — dev — 2026-09-24T10:25:20Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Finalized the production container deployment path with a minimized build context, loopback-bound Compose service, complete operator instructions, and an end-to-end smoke test. |
| Files Changed | .dockerignore, README.md, compose.yaml, scripts/test-production-deployment.sh, .ai/TASKS.md, .ai/REVIEW.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, and `go test ./...` passed in `golang:1.24`; `docker compose config` and `bash scripts/test-production-deployment.sh` passed. |
| Commit | `feat(deployment): add production container setup` |
| Verdict | PASS |
| Blocking Findings | none |
| Next Role | human approval |
---
### T-006 — commit_task — 2026-09-24T10:26:29Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Approved the reviewed T-006 production deployment work and prepared its single task commit. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | Review validation recorded in `.ai/REVIEW.md`. |
| Commit | `feat(deployment): add production container setup` |
| Next Role | none |
---

24
.ai/REVIEW.md

@ -108,3 +108,27 @@ for an authenticated standard user. The new storage operations use bcrypt,
force a password change after creates and resets, and invalidate sessions on
resets and disables. README documents the administrator workflow and the
standard-user boundary.
## T-006 — 2026-09-24T10:25:20Z
Verdict: PASS
Findings: none.
Validation:
- `go fmt ./...` (in `golang:1.24`): PASS
- `go vet ./...` (in `golang:1.24`): PASS
- `go test ./...` (in `golang:1.24`): PASS
- `docker compose config`: PASS; confirms loopback-only port publishing,
read-only configuration and root mounts, persistent SQLite volume, and the
restart policy.
- `bash scripts/test-production-deployment.sh`: PASS; builds the image,
verifies non-root runtime/no Go binary, serves `/login`, and redirects an
unauthenticated root request to login.
The review confirms that the final Alpine stage retains no Go toolchain, the
build context excludes repository-only artifacts, and both documented launch
paths preserve the root-configuration and persistent-data mounts. README now
covers Compose and Docker Run deployment, first-login administration, the
forced-password-change flow, and reverse-proxy TLS and Range requirements.

2
.ai/TASKS.md

@ -25,4 +25,4 @@ Command expectations:
| T-003 | Folder browsing UI: server-rendered root listing page, directory navigation with breadcrumbs, strict path confinement inside each configured root (block `..` traversal and symlink escapes), light/dark theme toggle, minimal vanilla JS/CSS (no SPA framework) | done | Authenticated user sees configured roots, can enter/exit subfolders via breadcrumbs; any crafted path that would resolve outside a root's filesystem boundary is rejected (test with `../` and a symlink pointing outside the root); theme toggle switches light/dark and persists per session/browser; `README.md` documents the browsing UI behavior | PASS: confined directory listing, traversal/symlink-escape rejection, authenticated root/folder/breadcrumb rendering, theme-local-storage markup; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none |
| T-004 | Download & video streaming: raw-byte download endpoint with correct `Content-Type`/`Content-Disposition`, full HTTP `Range` support for video files (seek/partial content/resume) with zero transcoding anywhere in the request path | done | Downloading any file returns identical bytes to the source file (checksum match); a `Range` request against a video file returns `206 Partial Content` with the correct byte range and headers; no encoding/thumbnailing/transcoding code path exists; `README.md` documents the reverse-proxy Range-header-passthrough requirement | PASS: root-confined regular-file opens; authenticated byte-identical attachment download; video `206` `Range` response; unauthenticated delivery gate; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none |
| T-005 | Admin user management: admin-only screens to list users, create a user (temporary password, `must_change_password=true`), reset a user's password, disable/delete a user; role-based route protection (403 for authenticated non-admins, not a redirect that leaks page existence) | done | Non-admin authenticated user gets 403 on all admin routes; admin can create a user who is then forced through the change-password gate on first login; admin can reset/disable a user; `README.md` documents the admin user-management flow | PASS: admin lifecycle/session-invalidation and HTTP role-protection/temporary-password tests; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24`; E2E not configured | none |
| T-006 | Docker packaging & deployment docs: finalize multi-stage Dockerfile (small image, no build tools in final image), finalize docker-compose example with volume mounts + root config, complete `README.md` (build/run, root config format, first-login/admin credential flow, forced password change, reverse-proxy TLS + Range passthrough requirement) | ready_for_implement | `docker build` produces a working image; `docker run` with a sample volume mount and root config serves the login page only until authenticated, per Priority 1; `README.md` is complete enough for a new operator to deploy without reading source code | n/a | implement |
| T-006 | Docker packaging & deployment docs: finalize multi-stage Dockerfile (small image, no build tools in final image), finalize docker-compose example with volume mounts + root config, complete `README.md` (build/run, root config format, first-login/admin credential flow, forced password change, reverse-proxy TLS + Range passthrough requirement) | done | `docker build` produces a working image; `docker run` with a sample volume mount and root config serves the login page only until authenticated, per Priority 1; `README.md` is complete enough for a new operator to deploy without reading source code | PASS: Compose configuration validation; production image build/run, non-root runtime, and unauthenticated login boundary smoke test; `go fmt ./...`, `go vet ./...`, `go test ./...` in `golang:1.24` | none |

9
.dockerignore

@ -2,3 +2,12 @@
.ai
.agents
.codex
.claude
AGENTS.md
CLAUDE.md
ROADMAP.md
ROADMAP.template.md
README.md
compose.yaml
config
scripts

61
README.md

@ -6,24 +6,51 @@ A lightweight, container-based file browser for named host folders mounted
read-only into a container. It provides login-gated browsing, downloads, and
native video streaming without server-side media processing.
## Getting Started
## Deploy with Docker Compose
Docker is the only prerequisite for the scaffold.
Docker and the Docker Compose plugin are the only prerequisites for the
included deployment configuration.
1. Update [`config/roots.example.json`](config/roots.example.json) with each
root's display name and its absolute path *inside the container*.
2. Mount the matching host folders at those paths. The included
[`compose.yaml`](compose.yaml) maps `${MEDIA_DIR:-./example-files}` to
`/mnt/media` as an example.
3. Start the service:
`/mnt/media` as an example. Set `MEDIA_DIR` to an absolute host path for a
production deployment.
3. Start the service in the background:
```bash
docker compose up --build
MEDIA_DIR=/srv/webfilebrowser/media docker compose up --build -d
```
The Compose service restarts unless stopped explicitly. It persists SQLite data
in the `webfilebrowser-data` named volume and mounts every configured browse
root read-only. The server listens on `http://localhost:8080` and logs every
configured root at startup. `/login` is the only public application route;
every other route redirects to login until it has a valid server-side session.
## Deploy with Docker Run
To use the image without Compose, build it, create the persistent data volume,
and mount both the roots configuration and the host folders named by it:
```bash
docker build --tag webfilebrowser:latest .
docker volume create webfilebrowser-data
MEDIA_DIR=/srv/webfilebrowser/media
docker run --detach --name webfilebrowser --restart unless-stopped \
--publish 127.0.0.1:8080:8080 \
--env WEB_FILE_BROWSER_ROOTS_FILE=/etc/webfilebrowser/roots.json \
--volume "$PWD/config/roots.example.json:/etc/webfilebrowser/roots.json:ro" \
--volume "$MEDIA_DIR:/mnt/media:ro" \
--volume webfilebrowser-data:/var/lib/webfilebrowser \
webfilebrowser:latest
```
The server listens on `http://localhost:8080` and logs every configured root
at startup. `/login` is the only public application route; every other route
redirects to login until it has a valid server-side session.
The image runs as an unprivileged `webfilebrowser` user and contains only the
compiled application and its Alpine runtime dependencies; Go and build tools
remain in the multi-stage build image. Keep the data volume when recreating or
upgrading the container so accounts and sessions persist.
## Root configuration
@ -77,7 +104,9 @@ server-side, use a seven-day sliding expiry, and are invalidated on logout.
The session cookie is always `HttpOnly`, `Secure`, and `SameSite=Lax`.
Terminate TLS at a reverse proxy before exposing the service; browsers will not
send the secure cookie over plain HTTP.
send the secure cookie over plain HTTP. Bind the application to loopback as in
the Docker Run example, terminate TLS at the reverse proxy, and forward the
original `Host` and `Range` request headers to the application.
## User administration
@ -121,6 +150,20 @@ application and preserve range responses such as `206 Partial Content`,
`Content-Range`, and `Accept-Ranges`. Do not buffer or replace these responses,
or native browser seeking will not work.
## Deployment smoke test
Run the documented end-to-end deployment smoke test after changing container
packaging or before promoting an image:
```bash
bash scripts/test-production-deployment.sh
```
It builds the image, starts an isolated container with temporary configuration,
media, and SQLite-volume mounts, verifies that the login page is public and
the root page redirects unauthenticated requests, then removes the temporary
container and volume.
## AI Workflow
This project includes the persistent planner/implementer/reviewer workflow with file-based coordination, plus the PO orchestration layer.

3
compose.yaml

@ -1,8 +1,9 @@
services:
webfilebrowser:
build: .
restart: unless-stopped
ports:
- "8080:8080"
- "127.0.0.1:8080:8080"
environment:
WEB_FILE_BROWSER_ROOTS_FILE: /etc/webfilebrowser/roots.json
volumes:

62
scripts/test-production-deployment.sh

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Build an isolated runtime image and verify its public authentication boundary.
set -euo pipefail
image_tag="${IMAGE_TAG:-webfilebrowser:e2e}"
test_suffix="${RANDOM}-$$"
container_name="webfilebrowser-e2e-${test_suffix}"
volume_name="webfilebrowser-e2e-data-${test_suffix}"
work_directory="$(mktemp -d)"
cleanup() {
docker rm --force "${container_name}" >/dev/null 2>&1 || true
docker volume rm --force "${volume_name}" >/dev/null 2>&1 || true
rm -rf "${work_directory}"
}
trap cleanup EXIT
mkdir -p "${work_directory}/media"
cp config/roots.example.json "${work_directory}/roots.json"
docker build --tag "${image_tag}" .
docker volume create "${volume_name}" >/dev/null
docker run --detach --rm --name "${container_name}" \
--publish 127.0.0.1::8080 \
--env WEB_FILE_BROWSER_ROOTS_FILE=/etc/webfilebrowser/roots.json \
--volume "${work_directory}/roots.json:/etc/webfilebrowser/roots.json:ro" \
--volume "${work_directory}/media:/mnt/media:ro" \
--volume "${volume_name}:/var/lib/webfilebrowser" \
"${image_tag}" >/dev/null
port="$(docker port "${container_name}" 8080/tcp | sed -n '1s/.*://p')"
base_url="http://127.0.0.1:${port}"
headers_file="${work_directory}/headers"
body_file="${work_directory}/body"
for _ in {1..30}; do
if curl --silent --output "${body_file}" --dump-header "${headers_file}" "${base_url}/login"; then
break
fi
sleep 1
done
if ! rg -q '^HTTP/[0-9.]+ 200' "${headers_file}" || ! rg -q 'Sign in' "${body_file}"; then
docker logs "${container_name}"
echo "container did not serve the login page" >&2
exit 1
fi
curl --silent --show-error --output "${body_file}" --dump-header "${headers_file}" "${base_url}/"
if ! rg -q '^HTTP/[0-9.]+ 303' "${headers_file}" || ! rg -qi '^Location: /login' "${headers_file}"; then
docker logs "${container_name}"
echo "unauthenticated root request was not redirected to login" >&2
exit 1
fi
if [[ "$(docker image inspect --format '{{.Config.User}}' "${image_tag}")" != "webfilebrowser" ]]; then
echo "container image must run as the webfilebrowser user" >&2
exit 1
fi
docker run --rm --entrypoint /bin/sh "${image_tag}" -c '! command -v go'
echo "production deployment smoke test passed"
Loading…
Cancel
Save