This guide walks an administrator through the initial configuration and the day-to-day operation of the Sprint Planner web application. It assumes a Linux host with Docker and Docker Compose v2 installed.
For architecture, schema, and feature history, see SPEC.md in the repository root.
docker compose plugin../data next to
docker-compose.yml; nothing else is needed for storage.git clone <repository-url> sprint_planer_web
cd sprint_planer_web
All commands in the rest of this manual are run from the repository root.
.envThe application reads its configuration from a single .env file in the
repository root. Start by copying the template:
cp .env.example .env
Open .env in an editor and fill in the values described below. The file
must be readable by the user that runs docker compose; on a shared host
restrict it with chmod 600 .env.
If your team will sign in with their Microsoft 365 / Entra accounts:
| Variable | What to put there |
|---|---|
ENTRA_TENANT_ID |
Directory (tenant) ID — a GUID, visible on the Entra "Overview" page. |
ENTRA_CLIENT_ID |
Application (client) ID of the app registration. |
ENTRA_CLIENT_SECRET |
A client secret you generated for the app registration. Treat as sensitive. |
In the Entra app registration, configure:
{APP_BASE_URL}/auth/callback,
e.g. https://sprint.example.com/auth/callback.openid, profile, email (delegated, default for
OIDC). Grant admin consent if your tenant requires it.Leave Entra fields blank if you only intend to use the local-admin
fallback. The OIDC sign-in button still appears, but clicking it will fail
until the variables are populated — keep LOCAL_ADMIN_EMAIL /
LOCAL_ADMIN_PASSWORD_HASH filled if you skip Entra entirely.
APP_BASE_URL=https://sprint.example.com
The base URL the application is reachable at, without trailing slash.
This is used to construct the OIDC redirect URI and must match exactly
what is registered in Entra. For local testing the default
http://localhost:8080 matches the default HTTP_PORT (see §3.3), so
no edits are needed unless you have changed one or the other.
HTTP_PORT=8080
The host port docker-compose.yml publishes for the app (container side
is fixed at Apache port 80). Default 8080. Pick any free host port; you
do not need to edit docker-compose.yml. Whatever you set here must
match the port in APP_BASE_URL and the redirect URI registered in
Entra.
DB_PATH=/var/www/data/app.sqlite
SESSION_PATH=/var/www/data/sessions
Leave the defaults unless you are also remapping the volume. The parent
directory /var/www/data is the volume mount point inside the container
and corresponds to ./data/ on the host.
APP_ENV=production
production silences verbose PHP errors. Any other value (e.g. dev)
turns them on — useful when troubleshooting in a non-public install.
TRUSTED_PROXIES=10.0.0.0/8,192.168.0.0/16
Set this when the application is exposed through a reverse proxy or load
balancer (Nginx, Traefik, Caddy, an AWS ALB, etc.). Provide a
comma-separated list of CIDRs that contains every hop between the public
internet and the PHP container. Bare addresses without /n are treated as
host masks (/32 for IPv4, /128 for IPv6).
When the immediate peer matches one of those CIDRs the application will:
X-Forwarded-For rightmost-to-leftmost to pick the originating
client IP — this is what lands in the audit log and the local-admin
login-throttle bucket (R01-N06 / R01-N07); andX-Forwarded-Proto: https so the session cookie keeps its
Secure flag and the response carries Strict-Transport-Security
even when the proxy → container link is plain HTTP (R01-N05).Leave the variable blank when the container is exposed directly. With no trusted proxies configured the application ignores both forwarded headers, so a hostile client cannot lie its way into a different audit IP or into HTTPS-only behaviour.
Proxy contract. Whichever proxy you put in front of the app must:
X-Forwarded-For (most reverse
proxies do this by default, but verify);X-Forwarded-Proto — this is the
single signal the application uses to decide whether the user-facing
request was over TLS;TRUSTED_PROXIES.If APP_BASE_URL is https://… and the application detects that the
live request is genuinely over HTTP (either no proxy at all, or a trusted
proxy that explicitly reports X-Forwarded-Proto: http), it issues a
permanent redirect to the canonical HTTPS URL. /healthz is exempt so
liveness probes keep working over either scheme. The redirect is
suppressed when the proxy is silent about the scheme — there is no safe
way to distinguish "real HTTP request" from "TLS-terminating proxy that
forgot to set the header", and a redirect in the latter case would
infinite-loop.
A typical Nginx snippet:
location / {
proxy_pass http://sprint_planer:80;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Historically the first user to complete sign-in via any path was promoted
to administrator. On a public-facing first deploy that is a land-grab risk
— anyone with a valid Entra account in your tenant could win the race
against the intended operator and lock everyone else out
(see doc/REVIEW_01.md finding R01-N03).
The OIDC sign-in path now auto-promotes only when the signing user matches an explicit env-bootstrap value:
BOOTSTRAP_ADMIN_OID=00000000-0000-0000-0000-000000000000
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
| Variable | What to put there |
|---|---|
BOOTSTRAP_ADMIN_OID |
The Entra oid claim (a GUID, immutable for the lifetime of the user) — preferred when known. Visible on the user's "Object ID" line in the Entra portal. |
BOOTSTRAP_ADMIN_EMAIL |
The user's primary email. Accepted as a fallback when only the email is on hand. Matched case-insensitively, after trimming. |
Either / both / neither may be set:
users.is_admin = 1 directly in app.sqlite.Auto-promotion additionally requires that no administrator already exists
(countAdmins() === 0). Once a first admin is in place, subsequent
promotions go through the Users page (§5.1). The promotion is recorded
in the audit log as BOOTSTRAP_ADMIN with via=oidc.
The local-admin fallback (§3.8) is itself an explicit env-bootstrap and
does not require the variables above — its BOOTSTRAP_ADMIN audit row is
tagged via=local.
LOCAL_ADMIN_EMAIL=admin@example.com
LOCAL_ADMIN_PASSWORD_HASH='$2y$10$...' # output of password_hash()
LOCAL_ADMIN_NAME=Local Admin
If both LOCAL_ADMIN_EMAIL and LOCAL_ADMIN_PASSWORD_HASH are set, a
second sign-in form appears at /auth/local. The submitted password is
verified with PHP's password_verify() against the stored bcrypt hash —
the plaintext password is never written to disk.
Generating the hash. Pick whichever one-liner matches what you have installed; both prompt interactively so the password isn't kept in shell history:
# Host PHP 8 (any minor):
php -r 'echo password_hash(readline("Password: "), PASSWORD_DEFAULT), PHP_EOL;'
# No host PHP — borrow the runtime image:
docker run --rm -it php:8.3-cli php -r \
'echo password_hash(readline("Password: "), PASSWORD_DEFAULT), PHP_EOL;'
Each invocation prints a different $2y$10$... string for the same
input — that's the per-hash random salt. Either one verifies.
Paste the result into .env between single quotes so the shell that
launches docker compose doesn't try to expand the $ segments inside
the hash:
LOCAL_ADMIN_PASSWORD_HASH='$2y$10$abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnop'
Then:
.env is chmod 600 and owned by a single trusted user..env, and restarting the container — active sessions stay valid
until they expire (the hash is only consulted at sign-in time).The local-admin user is recorded in the database as
entra_oid = "local:<email>" with is_admin = 1, and is always promoted
to admin on every successful local sign-in (so a manual demotion in the
Users page is undone on the next local sign-in). When the users table
was empty before the sign-in, an audit entry of type BOOTSTRAP_ADMIN
with via=local is also recorded.
For OIDC-only deployments — where you don't want the local fallback at
all — leave LOCAL_ADMIN_EMAIL / LOCAL_ADMIN_PASSWORD_HASH blank and
nominate the first admin via the OIDC bootstrap (§3.6).
To disable the fallback later, blank both variables and restart.
The project ships a multi-stage Dockerfile (Node-based Tailwind build →
PHP 8.3 + Apache runtime) and a docker-compose.yml that wires the
container to the host.
Operators vs. developers. Everything in this section is the production deployment path: the bare
docker composecommands targetingdocker-compose.ymlonly. Developers iterating on code use a separatedocker-compose.dev.ymloverlay (bind mounts +tailwindcss --watchsidecar) plus aMakefileof wrappers; that story lives inSPEC.md§11 and is not relevant to operating a deployment.
docker compose up --build
What happens:
assets/css/input.css into public/assets/css/app.css. The PHP
stage installs Composer dependencies and copies the application
source into /var/www/html.bin/docker-entrypoint.sh) runs
php bin/migrate.php to create app.sqlite inside the volume and
apply every file in migrations/ BEFORE Apache binds the port. If
a migration fails, the container exits non-zero and Apache never
starts — check docker compose logs for the migration's stderr.HTTP_PORT in .env (default 8080 — see §3.3 / §4.5).The web request path itself never applies SQL — it only checks that
schema_version matches the on-disk migration set and refuses to serve
(503 Service Unavailable, Retry-After: 30) when something is pending.
A forgotten deploy step therefore produces a loud, fast-failing 503
instead of silent stale-schema serving (R01-N22).
Open http://<host>:8080. If you used the local-admin fallback, sign
in at /auth/local. Otherwise click the Entra sign-in CTA on /.
docker compose up -d --build
Logs are then read with:
docker compose logs -f
Stopping the stack:
docker compose down
down keeps the ./data volume intact — your database and sessions
survive. To wipe and start over, stop the stack and remove ./data
manually (see §5.4).
You only need to rebuild the image when the build inputs change:
Dockerfile, composer.json / composer.lock, or package.json /
package-lock.jsonTailwind sources under assets/css/ or any view file that introduces
new utility classes (the css-builder stage scans the views).
docker compose build --no-cache && docker compose up -d
For pure PHP / view edits without a rebuild, restart the container:
docker compose restart
The application exposes an unauthenticated GET /healthz route that
returns 200 OK with a small JSON body. Use it from a load balancer or
uptime monitor:
curl -fsS http://<host>:8080/healthz
Set HTTP_PORT in .env to any free port on the host:
HTTP_PORT=9090
docker-compose.yml substitutes the variable into the ports mapping
("${HTTP_PORT:-8080}:80"); the default 8080 applies when the
variable is unset. After editing, docker compose up -d re-creates the
container with the new port — no rebuild required. Update APP_BASE_URL
in .env and the redirect URI in Entra to match.
Once the bootstrap admin is signed in, additional administrators are managed through the web UI:
Guardrails enforced server-side:
If both guardrails fire at once — i.e. you are the only admin — the only safe escape is to sign in via the local-admin fallback (if configured) and promote a second account first.
Users are never deleted — the users.id column is a foreign key for
audit rows and dropping it would break the trail. To honour an
erasure-of-personal-data request, mark the user as a former user
("tombstone"):
(former user) in place of their email and
display name, both on this page and anywhere else the application
surfaces a user object. The row keeps its position so audit-log
joins and the admin counter still work.Important details:
is_admin = 0 — a tombstoned admin is
nonsensical (the UI hides their identity). The same demotion guard
rails apply: you cannot tombstone yourself, and you cannot tombstone
the last remaining administrator.audit_log.user_email still carries
the original address verbatim. Erasure regulations under GDPR /
similar frameworks typically permit retention of audit data for
legal and security purposes — the Sprint Planner takes that
position. If your jurisdiction requires audit-row redaction too,
you must edit app.sqlite manually and document the redaction
procedure separately.tombstoned_at but does NOT auto-promote the
user; if they need admin again, set the admin checkbox separately.The audit log records both transitions as TOMBSTONE and RESTORE
on entity_type = user, so the privacy operation itself is itself
auditable.
Everything that needs a backup lives under ./data/:
app.sqlite — the database, including audit log.sessions/ — active PHP session files. Losing these only forces
re-login, no data loss.A safe nightly backup is a straight file copy while the container is
running. SQLite handles concurrent reads fine; for a guaranteed
consistent snapshot, prefer sqlite3 .backup:
docker compose exec app sqlite3 /var/www/data/app.sqlite \
".backup /var/www/data/app.sqlite.bak"
cp ./data/app.sqlite.bak /your/offsite/backup/location/
Every create / update / delete on a domain entity is recorded, plus sign-in events. Admins can browse and filter the log via the hamburger menu → Audit log, with filters by entity type, action, user, and date range, and collapsible JSON diffs per row.
To wipe all data and start from a blank slate:
docker compose down
rm -rf ./data
docker compose up -d
Migrations run on the next container start (entrypoint), before Apache binds the port. The first administrator is re-seeded as described in §3.6 (OIDC bootstrap) or §3.7 (local-admin fallback) — there is no longer an unconditional "first sign-in becomes admin" path.
git pull
docker compose build --no-cache
docker compose up -d
Schema migrations under migrations/ run from the entrypoint as the
container starts (R01-N22) — bin/docker-entrypoint.sh calls
php bin/migrate.php before apache2-foreground. Apache only binds
the port if migrations succeed. Always take a backup of
./data/app.sqlite before pulling.
Running migrations outside Docker. If you deploy the code without the container (e.g. a bare PHP-FPM host), apply migrations manually as part of the release procedure, BEFORE restarting the web server:
php bin/migrate.php
# → migrate: schema already current (version N)
# → or: migrate: applied K migration(s): … schema now at version N
If a deploy ever skips this step, the request path will return
503 Service Unavailable with Retry-After: 30 until the operator
runs bin/migrate.php. The application server log carries a single
line listing the pending filenames. The 503 is intentional: the
alternative — auto-migrating on a request that may then crash mid-DDL
— is the failure mode this design rules out.
Composer dependency cadence (R01-N16). The XLSX import wizard is
backed by PhpSpreadsheet,
which has a long history of XML-related advisories. The composer.json
caret range (^3.4) lets minor upgrades land on each docker compose
build --no-cache, but the operator is responsible for rebuilding
promptly when a new release ships. Recommended cadence: rebuild after
any git pull, and at minimum monthly even on a quiet branch. Run the
auditor to surface known CVEs in the currently locked versions:
./bin/audit.sh
# → "No security vulnerability advisories found." when clean.
# → exit 1 + a vulnerability table when an advisory matches.
The script wraps composer audit --locked inside the runtime image so
the audit reflects the exact dependency tree the live container runs.
A weekly cron is a low-friction option:
# /etc/cron.d/sprint-planner-audit
0 7 * * 1 www-data cd /opt/sprint-planer-web && ./bin/audit.sh \
|| mail -s 'sprint-planer composer advisory' admin@example.com
The OIDC handshake stores its state and PKCE code_verifier in the
PHP session under fixed keys. If you start a sign-in flow in two
browser tabs at the same time, the second tab overwrites the first
tab's state — when you finish the first tab, the callback rejects the
state mismatch and you are bounced back to /?auth_error=1. This is
not a security issue (the rejection is the correct OIDC behaviour),
but it can be confusing. Complete one sign-in at a time, or close
the older tab before starting a fresh login.
PHP's built-in session GC is probabilistic — it only fires on request
traffic with a small probability per request. On a quiet deployment the
session directory accumulates stale files for days past the configured
gc_maxlifetime (8h). The container's entrypoint compensates by
launching a backgrounded find … -mmin +480 -delete sweep every hour:
# bin/docker-entrypoint.sh — see source for the full loop.
find "${SESSION_PATH}" -mindepth 1 -type f -mmin +"${SESSION_GC_MAX_AGE_MINUTES}" -delete
Two env vars override the defaults:
| Variable | Default | Meaning |
|---|---|---|
SESSION_GC_MAX_AGE_MINUTES |
480 |
Delete session files older than N minutes (= 8h). |
SESSION_GC_INTERVAL_SECONDS |
3600 |
Sweep every N seconds (= once per hour). |
The loop is a child of PID 1 inside the container, so docker stop
propagates and reaps it together with Apache. No additional packages
are installed — find ships with the php:8.3-apache base image.
If you deploy outside Docker (bare PHP-FPM host), wire an equivalent sweep yourself, e.g. a system cron entry:
17 * * * * www-data find /var/www/data/sessions -mindepth 1 -type f -mmin +480 -delete
| Symptom | Likely cause | Fix |
|---|---|---|
/auth/login returns "redirect URI mismatch" |
The redirect URI registered in Entra does not equal {APP_BASE_URL}/auth/callback. |
Update either APP_BASE_URL in .env or the Entra app registration. |
/auth/local returns 404 |
LOCAL_ADMIN_EMAIL or LOCAL_ADMIN_PASSWORD_HASH is blank. |
Set both (see §3.7 for the hash recipe), then docker compose restart. |
| Sign-in succeeds but every page shows "not authorised" | The user has no is_admin flag and is trying to access an admin-only page. |
Promote them via Users (logged in as another admin). |
| Container restarts in a loop | Most often a malformed .env line or a permission problem on ./data. |
docker compose logs will show the PHP fatal. Check that ./data is writable by the www-data user inside the container (uid 33 on the Apache image). |
| CSS looks broken / unstyled | The Node build stage was skipped or used a stale layer. | docker compose build --no-cache and restart. |
| OIDC works but PHP 8.4 deprecation warnings appear in logs | Known upstream issue in jumbojett/openid-connect-php. |
Harmless on PHP 8.3 (the shipped runtime); ignore until upstream releases a fix. |
For anything else, check the audit log first — most user-facing errors also leave a trail there with the exact request that failed.