A single-container image running JobTrack.Web against SQLite, intended for a quick throwaway
instance on a developer machine (this was built and verified under OrbStack on macOS/arm64). The
image is built from ../../Dockerfile.
If you need state to survive, this is the wrong image: see
postgresql-cloud-run-deployment.md, a second, independent
configuration (Dockerfile.postgresql) running against a persistent Cloud SQL PostgreSQL instance,
with no example job nodes and three randomly generated credentials. It does not replace anything
here — both paths are maintained side by side.
This is not the project's deployment story. ADR 0014
fixes a single bare-metal/VM server with a dedicated unprivileged service account behind a locally
managed reverse proxy, and explicitly defers containers and orchestration until a measured
requirement justifies them. production-deployment.md remains the real
runbook. Several choices below (a baked-in self-signed certificate, a placeholder trusted-proxy
address, an unencrypted data-protection key ring) are acceptable only because this image is a local
demo artifact — see "What makes this demo-only" at the end, which is the list to work through if
this ever needs to become a real deployment target. Most importantly, it ships two known,
published demo credentials (demo / demo-jobtrack-1234 and requester / requester-jobtrack-1234) baked in at
build time, so it must never be exposed to a network with either account reachable. The privileged
admin account gets a random password generated at build time (never a known default): unknown
for a plain local docker run and, on Cloud Run, an explicit random one the deploy script generates
and prints (see "Cloud Run smoke test"). None of the three accounts forces a password change on
first sign-in.
The two providers are mutually exclusive per deployment, not a failover pair, and SQLite is a fully
conforming backend rather than a reduced-feature fallback (see the README's "Dual-provider
persistence"). Choosing it here keeps the whole demo to one container with no separate database
server, no second image, and no orchestration — the "embedded/single-node deployment
where running a separate PostgreSQL server isn't warranted" case SQLite exists for. Its operational
envelope is documented in sqlite-limitations-and-configuration.md.
The build context is the monorepo root, not JobTrack/. .editorconfig lives one
level above JobTrack/ (the git root is the parent directory), and the build runs with
TreatWarningsAsErrors. A context rooted at JobTrack/ omits that file, so the analyzer
directory-walk misses its severity overrides and the build fails on rules the local build never
hits (VSTHRD103, downgraded to none there as a duplicate of CA1849). The Dockerfile copies
.editorconfig and the JobTrack/ subtree separately to mirror that real layout inside the image.
Run from JobTrack/:
docker build -f Dockerfile -t jobtrack-web ..Dockerfile.dockerignore (not .dockerignore — the name is context-relative and the context is the
parent) ignores everything by default and re-includes only .editorconfig and JobTrack/, so
sibling monorepo projects never enter the context.
The image is ~213MB, on a 131MB mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled base.
Chiseled (distroless-style: no shell, no package manager, non-root by default). Two consequences worth knowing before changing anything:
- No shell.
docker exec ... shandlsdon't exist in the image; inspect a container withdocker cpinstead. A RID-targeted publish emits an apphost, so entrypoints invoke the binary directly rather than through thedotnetmuxer, which the image also doesn't ship. - No ICU, so the base defaults to
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true. This is safe here only because the codebase usesCultureInfo.InvariantCultureexclusively (there are noCurrentCulture/CreateSpecificCulturecall sites) and Noda Time carries its own TZDB data inNodaTime.TimeZonesrather than reading system ICU/tzdata. Code that starts depending on ICU must move to the-chiseled-extratag; the failure mode would be a runtime culture exception, not a build error.
wwwroot/lib/ (Bootstrap, Mulish) is gitignored and restored via LibMan rather than vendored, so
the build stage installs Microsoft.Web.LibraryManager.Cli and runs libman restore from
src/JobTrack.Web before publish. It is not copied from the build context, and the pinned versions
come from libman.json as usual — bump them there, never by hand.
The image bundles three framework-dependent, ReadyToRun apphosts under WORKDIR /app:
| Path | Purpose |
|---|---|
./web/JobTrack.Web |
the application (default ENTRYPOINT) |
./database/JobTrack.Database |
one-time schema deployment |
./admincli/JobTrack.AdminCli |
one-time administrator bootstrap, emergency password reset |
The two CLIs are reached with --entrypoint, which resolves relative to WORKDIR /app — hence the
./database/... and ./schema-versions paths in the commands below.
Because those apphosts sit in sibling subdirectories rather than at /app itself, the web app's
content root would otherwise default to the process working directory (/app) and WebRootPath
would resolve to the non-existent /app/wwwroot. This fails silently: MapStaticAssets still
matches its routes (the endpoint manifest is found next to the assembly, so requests return 200
rather than 404) but serves every asset as a zero-byte body — an unstyled site with no startup
error and nothing in the logs. ENV ASPNETCORE_CONTENTROOT=/app/web pins it; keep it in step with
the web apphost's directory if that ever moves. Confirm with the startup log line
Content root path: /app/web, and verify assets return real byte counts, not just 200.
Program.cs sets the authentication cookie to Secure-only unconditionally — a deliberate
fail-closed default. Over plain HTTP the browser silently discards the cookie, so sign-in appears to
succeed server-side and then bounces straight back to the login page with no visible error (the same
trap documented in local-live-instance.md). The image therefore listens
on HTTPS :8443 only and clears the base image's default ASPNETCORE_HTTP_PORTS=8080 rather than
leaving an unusable plaintext listener.
A self-signed certificate from dotnet dev-certs is generated at build time and baked in at
/app/certs/devcert.pfx. Browsers will warn; that is expected. The --chown on its COPY is
required, not cosmetic: dev-certs writes the file 0600 owned by root, which the non-root
APP_UID cannot read, and Kestrel then fails to bind at startup with
UnauthorizedAccessException.
The image ships a pre-seeded demo database, so there is no setup:
docker run --rm -p 8443:8443 -v jobtrack-data:/app/data jobtrack-webOpen https://localhost:8443 and sign in with either non-admin demo account:
| Purpose | Username | Password |
|---|---|---|
| Staff workflow and sample job trees | demo |
demo-jobtrack-1234 |
| Submit and track six sample requests | requester |
requester-jobtrack-1234 |
The image bakes in three accounts (see "How the accounts and trees are seeded"): the two
published non-admin accounts above, plus a privileged admin account whose password is random
(no known default; unknown for a local docker run unless you passed
--build-arg ADMIN_PASSWORD).
None of these accounts forces a password change on first sign-in — all are seeded with
--no-force-password-change, so the published credentials work repeatedly rather than only once.
That is a deliberate demo affordance, not the ADR 0023 default, which every normally provisioned
account still gets.
/app/data holds both the SQLite database and the data-protection key ring. A new named volume is
empty, so Docker populates it from the image — which is how the seeded accounts and trees reach the
volume. An existing volume is left untouched, so any changes you make survive a
docker rm / docker run cycle.
Nothing is ever written back to the image. It is immutable and acts purely as a seed. Everything
you create in the app — jobs, work sessions, employees, rates, audit rows, password changes — lands
in one SQLite file, /app/data/jobtrack.db, on the volume mounted there. The data-protection key
ring sits beside it in /app/data/keys.
| Action | Data |
|---|---|
docker stop / docker start |
kept |
docker rm the container, docker run a new one on the same volume |
kept — the container is disposable, the volume is not |
docker build a new image (even with a different demo password) |
kept, and the new seed is ignored — see below |
docker volume rm jobtrack-data |
gone, back to the pristine seed (three accounts, sample trees, and six requester jobs) |
docker run with no -v |
gone on exit — see the trap below |
Verified rather than assumed: a password changed through the browser survived
docker rm -f jobtrack followed by a fresh docker run on the same volume.
A new named volume is empty, so Docker populates it from the image's /app/data — that is how the
seeded demo account gets there. An existing volume is never touched. The consequence catches
people out: rebuilding the image does not update your database. Change the demo credentials, add
a schema version, rebuild — an existing jobtrack-data keeps the old contents, and the app may then
run against a schema older than the binaries expect. docker volume rm jobtrack-data is what picks
up a new seed.
VOLUME /app/data in the Dockerfile means Docker always gives that path a volume. Omit -v and
you get an anonymous one — so the app still works and still writes, but:
docker run --rm -p 8443:8443 jobtrack-web # <-- every job you create is destroyed on exit--rm removes the container and its anonymous volumes. Without --rm the data survives, but in a
volume with a random hex name that is hard to identify later. Always pass
-v jobtrack-data:/app/data.
SQLite runs in WAL mode, so recent commits may live in jobtrack.db-wal and not yet be in the main
file. Copying jobtrack.db alone gives a silently stale snapshot — this is not theoretical; it
happened while writing this doc and showed a stale requires_password_change and
access_failed_count, which sent the diagnosis off in the wrong direction entirely. Take all three
files, or stop the container first so the WAL is checkpointed on close:
docker stop jobtrack
for f in jobtrack.db jobtrack.db-wal jobtrack.db-shm; do docker cp "jobtrack:/app/data/$f" .; done
docker start jobtrackBack up /app/data/keys in step with the database. It is not optional convenience: restoring the
database without its key ring invalidates every existing session and antiforgery token
(web-host-security.md).
For anything resembling real use, PostgreSQL and
postgresql-backup-restore.md are the answer — a Docker volume is
not a backup strategy, and this image is not a deployment (see the top of this document).
Six steps run at build time (see the /appdata block in the Dockerfile). Account and tree writes
use the shipped JobTrack.AdminCli; the final requester scenario uses the build-only
JobTrack.UatSeed apphost and the reusable library:
-
Schema deploy (
JobTrack.Database deploy). -
Bootstrap the
admin(AdminCli bootstrap) — one atomic operation creating administrator id 1 and root job node id 1. Its password is random (a known default is never baked in): theADMIN_PASSWORDbuild-arg if supplied, otherwise one generated from/dev/urandomin the build step and recorded nowhere.--no-force-password-changeclears the ADR 0023 flag, since a forced change on a baked-in credential that reverts on every recycle is pointless friction. -
Create the
demouser (AdminCli create-employee) — a normal, non-adminJobManager+Workeremployee (demo/demo-jobtrack-1234), also--no-force-password-changeso the published credential stays reusable. Employee id 2. -
Create the
requesteruser (AdminCli create-employee) —Client Requester, holding only theRequesterrole (requester/requester-jobtrack-1234), with the same reusable-demo credential treatment. It cannot be assigned work. Employee id 3.Both fixed demo credentials satisfy
PasswordPolicy; the production command path has no weak- password bypass. -
Import the seven sample trees (
AdminCli import-tree, once per file insamples/job-tree-imports/) asdemo, so the demo user — not the admin — owns them. Each lands a subtree under the root (--parent-iddefaults to the root, id 1).building-a-house.jsonflags its top node"home": true, so "Build a house" becomes the home node ofdemo(the importing account) and, via--home-node-for admin, ofadmintoo: both sign in onto that subtree rather than the bare root, and the header's Jobs and Awaiting-progress links default to it. The remaining six files flag nothing and are unaffected.requesteris left without one — the requester UI has no job-tree browser. -
Seed six requester jobs through
IJobTrackClient's requester-intake and work commands.requesteris the recorded requester, whiledemoremains the technical work actor; the jobs span Submitted, Accepted, Waiting, In progress, Completed, and Cancelled public states.
Bootstrap takes its password non-interactively via --password (it still prompts for display
name / time zone / username, which the build pipes on stdin); create-employee and import-tree
are fully non-interactive. Passing a password in argv is visible in the process list and shell
history — an explicit trade-off accepted here for an automated demo-image build, exactly as
BootstrapCommandOptions.Password documents.
Only needed against a database that has no administrator (the shipped one already has the admin
account). Interactive, so it needs -it and a real terminal — it cannot be scripted:
docker run --rm -it -v jobtrack-data:/app/data --entrypoint ./admincli/JobTrack.AdminCli \
jobtrack-web bootstrap --provider sqlite \
--connection-string "Data Source=/app/data/jobtrack.db"Schema deployment is not idempotent against a database that already has JobTrack's tables, so this pairs with a volume you have reset, not the seeded one. The deploy step itself, if you need it:
docker run --rm -v jobtrack-data:/app/data --entrypoint ./database/JobTrack.Database \
jobtrack-web deploy --provider sqlite \
--connection-string "Data Source=/app/data/jobtrack.db" --scripts-root ./schema-versions| Variable | Value | Why |
|---|---|---|
ASPNETCORE_CONTENTROOT |
/app/web |
see above — silent empty-asset bug without it |
Database__Provider |
Sqlite |
one container, no separate server |
ConnectionStrings__JobTrackIdentity |
Data Source=/app/data/jobtrack.db |
on the named volume |
DataProtection__KeyPath |
/app/data/keys |
required outside Development; on the volume so a re-created container keeps the key ring |
ForwardedHeaders__KnownProxies__0 |
127.0.0.1 |
required outside Development — a placeholder, see below |
Kestrel__Endpoints__Https__* |
:8443, baked cert |
Secure-only cookie needs HTTPS |
DataProtection:KeyPath and the forwarded-headers settings are both fail-closed startup
requirements outside Development (web-host-security.md). Keeping the key
ring on the volume matters: lose it and every existing session and antiforgery token invalidates.
This image was smoke-tested on Google Cloud Run as a quick "does the container actually work outside my machine" check, not as a deployment recommendation — see the top of this document and ADR 0014. The two things that made this safe to do at all:
- The privileged
adminaccount gets a random password the script generates and prints, since Cloud Run is network-exposed and a known admin credential must never be reachable. The publisheddemo/demo-jobtrack-1234andrequester/requester-jobtrack-1234accounts are left as-is — both are normal, non-admin users with no account-management rights, and their whole point is to be shareable. Any change a visitor makes is wiped back to the seed on the next recycle (see below). - An HTTP endpoint added at deploy time, on top of the existing HTTPS one, because Cloud Run's
fully-managed product terminates TLS at Google's front end and always proxies to the container
over plain HTTP on
$PORT— it does not do TLS passthrough to a container-side certificate.Program.csalready supports this:ForwardedHeaders(XForwardedFor/XForwardedProto) runs beforeUseHttpsRedirection(), so a request forwarded withX-Forwarded-Proto: httpsis treated as already secure andCookieSecurePolicy.Alwaysstill works correctly. This only needed a deploy-time env var, not an image change:Kestrel__Endpoints__Http__Url=http://+:8080alongside the image's existingKestrel__Endpoints__Https__*(which goes unused — nothing reaches the container on 8443 through Cloud Run). The baked-inForwardedHeaders__KnownProxies__0=127.0.0.1is likewise inert here;ForwardedHeaders__KnownNetworks__0=0.0.0.0/0is what does the job, and is reasonable specifically because Cloud Run does not allow direct public access to the container — only Google's own front end can ever be the thing setting those headers.
../../scripts/deploy-cloudrun.sh does all of this —
build with a freshly generated ADMIN_PASSWORD (the two demo credentials stay published), push to
Artifact Registry, deploy — and prints all three logins at the end, since nothing else records
the random admin one:
./scripts/deploy-cloudrun.sh <gcp-project-id> [region] # region defaults to europe-west1europe-west1 (Belgium) is a Tier 1 GCP pricing region, so the Always Free allowance and
per-unit cost both go further than in europe-west2 (London), which is Tier 2 — pick that
default over a same-continent alternative that costs more for no functional benefit.
It assumes an existing Artifact Registry Docker repo named cloud-run-source-deploy in that
project/region — create one first if the target project doesn't already have it (gcloud artifacts repositories create cloud-run-source-deploy --location=<region> --repository-format=docker).
--platform linux/amd64 in the script matters when building on Apple Silicon — Cloud Run's default
runtime is amd64, and OrbStack's local Docker defaults to the host's arm64.
Deploy target: jobtrack-demo-projects, not the persistent deployment's project. This demo
(and EnrolmentRules' enrolment-web) was relocated out of the project holding jobtrack-web-pg
so that a compromise of this public, credential-published demo cannot reach the persistent
deployment's Cloud SQL instance, secrets, or data-protection key ring — see
../plans/2026-08-06-cloudrun-persistent-isolation-plan.md.
The script also creates a dedicated demo-run service account with no IAM roles at all and deploys
under it — the default compute service account is not used, since in a project holding
anything sensitive it can carry unrelated standing roles this service does not need.
The admin password is different on every run. The script always generates a fresh one and passes
it via --build-arg ADMIN_PASSWORD; nothing pins it between deploys, and it is printed once at the
end because it is recorded nowhere else. The two non-admin passwords remain the published
demo-jobtrack-1234 and requester-jobtrack-1234.
This deployment has no persistent volume. Cloud Run containers are stateless and ephemeral —
/app/data is just the image's writable layer, so every cold start (scale-to-zero is the default,
hence --min-instances=0) goes back to the baked-in seed with the random password above, and a
second concurrent instance (never happens here — --max-instances=1 — but would on a higher limit)
would not share state with the first. That is a materially different persistence model from the
named-volume setup described everywhere else in this document, and is fine only because this was a
short-lived reachability check, not a running demo meant to hold state.
Nothing you do in the running app is durable — every recycle wipes the database back to the baked seed. This is the crucial difference from the named-volume setup, and it is easy to get wrong. Any change made through the browser — the demo password, new accounts, jobs, work sessions, anything — is written only to the container's throwaway filesystem and is destroyed, not preserved, the moment Cloud Run replaces the instance. Recycles are routine and not on a fixed schedule: a scale-to-zero followed by the next visit's cold start, a redeploy, or a maintenance/load recycle Cloud Run decides on its own. After any of them the filesystem is restored from the image and the database is back to exactly its initial seed state.
Two consequences that specifically catch people out:
- Changing a password to something memorable does not stick. Sign in as
demo, set the password toa-memorable-demo-passphrase, and it lasts only until the next recycle — then it reverts to the bakeddemo-jobtrack-1234. The password is not "preserved"; it is wiped like everything else and restored to the baked seed value, which for demo happens to be the samedemo-jobtrack-1234as before (and for admin, the same random build-time value the deploy script printed). To bake in a different demo password you must rebuild with--build-arg DEMO_PASSWORD=...and redeploy. - Passwords only "rotate" on a rebuild, not on a recycle. A plain scale-to-zero cold start
restores whatever was baked into the current image (demo →
demo-jobtrack-1234, admin → its build-time random). A freshdeploy-cloudrun.shrun bakes a new random admin password — which is why a previously distributed admin password stops working after you redeploy; the demo one does not change.
If you need live changes to persist, this ephemeral-filesystem path is the wrong tool: use a real backing store (PostgreSQL / Cloud SQL, or a mounted volume), which this demo does not wire up.
Tear down when done — this is a real, billed, publicly reachable Cloud Run service for as long as it exists:
gcloud run services delete jobtrack-web --project=<project-id> --region=<region used to deploy> --quietEach of these is fine for a throwaway local instance and unacceptable for a real deployment. They are the gap list, not a backlog anyone has committed to:
- It ships a known, published credential (
demo/demo-jobtrack-1234), seeded at build time and published in this document. It is only a normal, non-admin user, but it is still a reachable sign-in — the strongest reason the image must never be network-exposed with that account live. (The privilegedadminaccount is safer by construction: its password is random, never a known default, and on Cloud Run the deploy script generates and prints a fresh one per deploy.) A real deployment provisions its administrator interactively, once, on the target host, and seeds no published credential at all. - The certificate is self-signed and its password is in the image, visible in
docker history. BuildKit flags this (SecretsUsedInArgOrEnv) and the warning is correct; it is tolerated only because the cert protects nothing. A real deployment terminates TLS at the reverse proxy with a real certificate and does not carry one in the image at all. ForwardedHeaders__KnownProxies__0=127.0.0.1names no real proxy. Kestrel here is reached directly, so nothing ever arrives with forwarded headers to trust; the value exists purely to satisfy the fail-closed check. Behind an actual proxy this must name that proxy's own address — a wrong value here is a spoofable client address/scheme, which is exactly what the check exists to prevent.- The data-protection key ring is unencrypted at rest (
No XML encryptor configuredat startup, expected here), and lives in a Docker volume rather than a directory restricted to a dedicated service account. - No reverse proxy, no HSTS in front, no backup of the volume, and SQLite's single-node envelope applies.
- The container runs as the image's non-root
APP_UID, which is the one hardening property that does carry over.