Self-hosted asset management for small teams. Track physical assets with custom categories, hierarchical locations, configurable fields, service scheduling, file attachments, and printable barcode labels.
- Frontend: Next.js 16 (App Router, Turbopack), React 19, Tailwind CSS 4, shadcn/ui
- Backend: PocketBase (SQLite, auth, file storage, realtime subscriptions)
- Labels: bwip-js for barcode/data-matrix generation, CSS
@media print
You need Node.js 20+, pnpm, and bash + curl (for the PocketBase setup script).
pnpm install
pnpm pb:setup # downloads the pocketbase binary into ./bin
cp .env.example .env.local # then set POCKETBASE_SUPERUSER_EMAIL + _PASSWORD
pnpm dev # runs Next.js + PocketBase side-by-side via mprocsThen open http://localhost:3000/setup and create the first admin account.
pnpm pb:bootstrap upserts a PocketBase superuser from the env vars — run it once after editing credentials, or whenever you rotate them.
iOS Safari will not hand out camera access over plain HTTP on a LAN IP — getUserMedia needs a secure context. For the scan feature:
pnpm dev:https # next + pocketbase, Next on HTTPS bound to 127.0.0.1 (loopback)
# or, if you need to reach it from another device on your LAN:
pnpm dev:next:lan # binds Next to 0.0.0.0 — only on a trusted networkOnly use dev:next:lan on a network you control: the /pb/* rewrite forwards requests to PocketBase at the same origin, so any device that can reach your laptop's IP (and accept the self-signed cert) can hit PB directly. Bind to loopback whenever possible.
Set NEXT_PUBLIC_POCKETBASE_URL=/pb in .env.local so the browser hits PocketBase through Next's origin (a rewrite in next.config.ts proxies /pb/* → the real PB instance — development builds only). Open https://<your-laptop-ip>:3000 on the phone and accept the self-signed cert. pnpm pb:seed populates the database with demo assets, locations, and service schedules for the walkthrough.
cp .env.example .env # fill in POCKETBASE_SUPERUSER_EMAIL / _PASSWORD
docker compose up --builddocker-compose.yml brings up two services:
pocketbase— PB 0.37 with./pb_datamounted as a volume, served on port8090app— the Next.js standalone build, served on port3000, pointed at the PB service over the compose network
Open http://localhost:3000/setup to create the first admin.
When running under Docker, the PocketBase container also upserts the configured
superuser on startup using POCKETBASE_SUPERUSER_EMAIL and
POCKETBASE_SUPERUSER_PASSWORD. That keeps the app's admin client credentials
in sync with the PocketBase data volume.
If you prefer to keep Docker settings in .env.local, run Compose with it explicitly:
docker compose --env-file .env.local up --buildThe Next.js container port is configurable with env vars:
APP_PORT— the port the app listens on inside the containerAPP_HOST_PORT— the host port published by Docker Compose
Example:
APP_PORT=4000
APP_HOST_PORT=4000
SITE_URL=http://localhost:4000
docker compose up --buildIf you do not already have a reverse proxy, there is an optional tls profile
that starts a Caddy front-end. Caddy will automatically obtain and renew a
certificate for a public domain via ACME/Let's Encrypt-compatible issuers.
Set these env vars first:
APP_DOMAIN=stowage.example.comNEXT_PUBLIC_POCKETBASE_URL=https://stowage.example.com/pbSITE_URL=https://stowage.example.com
Then start the TLS profile:
docker compose --profile tls up --buildNotes:
APP_DOMAINmust resolve publicly to the Docker host.- Ports
80and443must be reachable from the internet for certificate issuance. - If you already have nginx/Caddy/Traefik in front, skip the
tlsprofile and keep using your existing reverse proxy. - The optional Caddy service proxies
/pb/*to PocketBase so the browser can use PocketBase over the same HTTPS origin.
Stowage uses numbered releases such as 0.2.0, 0.2.1, and 1.0.0.
Each release publishes:
- a git tag like
v0.2.1 - a GitHub Release with human-readable notes
ghcr.io/kroqdotdev/stowage-app:<version>ghcr.io/kroqdotdev/stowage-pocketbase:<version>ghcr.io/kroqdotdev/stowage-caddy:<version>ghcr.io/kroqdotdev/stowage-updater:<version>docker-compose.release.ymlas a release deployment bundle
For production/self-hosted deploys, pin exact image tags instead of floating to a new version unintentionally.
Image contents:
stowage-appcontains the production Next.js standalone server plus static/public assetsstowage-pocketbasecontains the pinned PocketBase binary plus this repo'spb_migrations/stowage-caddycontains the bundled Caddy config for TLS termination and/pbproxyingstowage-updateris the optional sidecar behind one-click updates (see Updating)- none of the images contain your persistent PocketBase data; that still lives in
pb_data/
See RELEASING.md for the release checklist and tag flow.
Tagged releases can now be deployed directly with docker-compose.release.yml.
App only:
STOWAGE_VERSION=0.2.0 docker compose -f docker-compose.release.yml up -dUse this when PocketBase and/or your reverse proxy already live elsewhere. In
that mode, set POCKETBASE_URL and NEXT_PUBLIC_POCKETBASE_URL to your
existing PocketBase endpoints.
App + PocketBase:
STOWAGE_VERSION=0.2.0 docker compose -f docker-compose.release.yml --profile pocketbase up -dFull stack (app + PocketBase + TLS proxy):
STOWAGE_VERSION=0.2.0 docker compose -f docker-compose.release.yml --profile full up -dFor the full stack profile, set:
APP_DOMAINto your public domainSITE_URL=https://<APP_DOMAIN>NEXT_PUBLIC_POCKETBASE_URL=https://<APP_DOMAIN>/pb
By default, the release compose file binds the app and PocketBase ports to
127.0.0.1; set APP_HOST_BIND=0.0.0.0 and/or POCKETBASE_HOST_BIND=0.0.0.0
if you intentionally want them reachable off-host without Caddy.
Admins see the installed version and any newer release, with its notes, under Settings > Updates. The app asks GitHub Releases when an admin opens the page (cached for an hour) and on demand with Check now.
By hand. Pin the new version and recreate the services, adding the profiles you deploy with:
STOWAGE_VERSION=0.3.0 docker compose -f docker-compose.release.yml --profile pocketbase pull
STOWAGE_VERSION=0.3.0 docker compose -f docker-compose.release.yml --profile pocketbase up -dOr set STOWAGE_VERSION in .env and run the same two commands without the
prefix. A variable exported in your shell wins over .env, so do not keep
STOWAGE_VERSION exported permanently or a later up -d will roll back to it.
PocketBase migrations bundled with the new image apply on start, and
pb_data/ is untouched, but take a backup first.
From the browser. Add the updater profile and a shared token, and the
Updates page gets an Update to x.y.z button:
# .env
STOWAGE_UPDATER_TOKEN=<output of: openssl rand -hex 32>
COMPOSE_PROFILES=pocketbase,updater # the profiles you deploy with, plus updater
docker compose -f docker-compose.release.yml up -dWhen an admin confirms an update, the sidecar pulls the new images for the
project's services, writes STOWAGE_VERSION=<new> into the project's .env
(or the files you passed with --env-file), recreates the services, and
finally recreates itself. The page shows the log and reloads when the new
version answers. A failed pull changes nothing: containers and .env stay as
they were. The sidecar only sees variables from those env files, not ones
exported in your shell, so keep deployment settings in .env.
The updater mounts the Docker socket, which is equivalent to root on the host, and is only reachable on the compose network with the token. Leave the profile out on hosts where that is not acceptable; the Updates page then shows the commands above instead.
The browser talks to PocketBase directly for realtime subscriptions, so for a public deployment put both services behind a reverse proxy on a single domain (Caddy, nginx, Traefik). Set NEXT_PUBLIC_POCKETBASE_URL to the proxy URL the browser should use (e.g. https://stowage.example.com/pb), and POCKETBASE_URL to the internal address Next uses to reach PB (e.g. http://pocketbase:8090). pb_data/ is the only stateful directory — back it up.
Environment variables:
| Var | Required | Notes |
|---|---|---|
APP_PORT |
no | Port the Next.js app listens on inside the container |
APP_HOST_PORT |
no | Host port mapped to the Next.js container |
POCKETBASE_HOST_PORT |
no | Host port mapped to PocketBase |
APP_DOMAIN |
no | Public domain for the optional built-in TLS profile |
HTTP_PORT / HTTPS_PORT |
no | Host ports published by the optional Caddy TLS service |
POCKETBASE_URL |
yes | Server-to-PB URL (e.g. http://pocketbase:8090) |
NEXT_PUBLIC_POCKETBASE_URL |
yes | Browser-to-PB URL (same origin as the app in prod) |
POCKETBASE_SUPERUSER_EMAIL / _PASSWORD |
yes | Used by pnpm pb:bootstrap and the app's admin client |
STORAGE_LIMIT_GB |
no | Global cap on total attachment bytes; omit for no limit |
SITE_URL |
no | Base URL embedded in label QR codes |
STOWAGE_VERSION |
no | Release image tag used by docker-compose.release.yml |
STOWAGE_UPDATER_TOKEN |
no | Shared secret between the app and the updater sidecar |
STOWAGE_UPDATER_URL |
no | Where the app reaches the updater (http://updater:9876) |
COMPOSE_PROFILES |
no | Profiles Compose treats as active (e.g. pocketbase,updater) |
STOWAGE_RELEASE_REPO |
no | GitHub repo whose releases are offered (default kroqdotdev/stowage) |
pnpm test # vitest jsdom suite (components, hooks, lib)
pnpm test:pb # vitest against a throwaway PocketBase instance (domain layer)
pnpm test:e2e # Playwright — boots PB + Next automatically
pnpm test:all # all threeE2E tests can pick up E2E_AUTH_EMAIL / E2E_AUTH_PASSWORD for the positive login path; without them, the auth flow tests fall back to the redirect-only checks.