Opinionated, hardened, daily-built self-hosted Snipe-IT asset-management deployment — a Docker Compose stack with our own slim PHP 8.5 / Alpine php-fpm image, an always-on Laravel scheduler + queue worker, scheduled phpbu backups, and dev overrides with mailpit and adminer for friction-free local development.
Made by Netresearch DTT GmbH on the back of a real Snipe-IT inventory evaluation. Battle-scarred defaults, not a barebones starter.
git clone https://github.com/netresearch/snipe-it-docker-compose-stack.git
cd snipe-it-docker-compose-stack
make init # bootstraps .env (APP_KEY + random DB passwords, idempotent)
make up # docker compose up -d
# First boot pulls ~700 MB of images (~5 min on a typical link) and runs
# `php artisan migrate --force` (~60-90 s). When `make health` is green:
# visit http://localhost:8000make help lists every target (artisan, backup, upgrade, health, …). For a full walkthrough see Quick start below.
Table of contents
- What's in the stack
- What's in our image
- Why does this exist?
- Quick start
- Common operations
- Dev mode
- TLS / reverse proxy
- Image tags
- Configuration
- Error tracking (Sentry / Bugsink)
- Security posture
- Backups
- Upgrading
- Operating the stack
- Migrating from the upstream single-container image
- Related projects
- Contributing
- License
┌────────────────────────────────────────────────┐
│ internal compose network (`snipeit`) │
│ │
client ──HTTP──► web ──┼──► app ── ghcr.io/netresearch/snipe-it-php-fpm ◄── built daily
(8000) nginx │ │ (php-fpm, handles web requests)
:alpine │ │
│ ├──► worker ── same image, runs
│ │ `php artisan queue:work`
│ │
│ ├──► db ── mariadb:11 (binlog enabled)
│ │
│ └──► valkey ── valkey/valkey:9-alpine
│ │
│ scheduler ── ghcr.io/netresearch/ofelia │
│ │ (runs `artisan schedule:run` per │
│ │ minute and triggers nightly phpbu) │
│ │ │
│ └─► backup ── ghcr.io/netresearch/phpbu-docker
│ │
│ one-shot init: app-assets populates the
│ shared `app-public` volume so nginx can
│ serve Snipe-IT's public/ files statically.
└────────────────────────────────────────────────┘
Only `web` is reachable from the host (port 8000 by default). Every other
service (db, valkey, app, worker, scheduler, backup) has no host-published
port in the default stack — they talk to each other on the internal network.
Seven long-running services plus a one-shot init:
| Service | Image | Purpose |
|---|---|---|
| db | mariadb:11 |
Primary store, binlog enabled for PITR |
| valkey | valkey/valkey:9-alpine |
Cache + sessions + queue backend (Redis-compatible) |
| app | ghcr.io/netresearch/snipe-it-php-fpm |
Our php-fpm image, Snipe-IT app code |
| web | nginx:alpine |
Static asset serving + fastcgi → app (unix socket) |
| worker | (same as app) |
php artisan queue:work daemon — drains async jobs from valkey (emails, EOL reminders, etc.) |
| scheduler | ghcr.io/netresearch/ofelia |
Label-driven cron for artisan schedule:run (per minute) and the nightly phpbu backup |
| backup | ghcr.io/netresearch/phpbu-docker |
Nightly DB dump + uploads/storage tarball with retention policy |
| app-assets | (same as app) |
One-shot init: syncs public/ into the shared app-public volume |
compose.yml is the canonical reference — heavily commented with sizing, security, and resource-limit rationale.
The php-fpm image (ghcr.io/netresearch/snipe-it-php-fpm) is intentionally narrow — just PHP + Snipe-IT app code:
| Base | php:8.5-fpm-alpine |
| PHP extensions | bcmath, exif, gd, intl, ldap, mbstring, opcache, pdo_mysql, redis, xml, zip |
| Runtime user | www-data (non-root) |
| Init | tini as PID 1 → entrypoint → php-fpm |
| Healthcheck | cgi-fcgi ping on /run/php-fpm/snipeit.sock |
| Multi-arch | linux/amd64, linux/arm64 |
| Supply chain | SLSA build provenance (in-toto via attest-build-provenance) + SBOM + keyless cosign signature (OIDC, Rekor-logged) on every push / scheduled build |
| License | AGPL-3.0-or-later (matches Snipe-IT upstream) |
The official snipe/snipe-it image is fine but conservative:
- ships PHP 8.3 (Ubuntu) or 8.4 (Alpine) — not the upstream-recommended 8.5
- the Alpine variant has no built-in scheduler, so Laravel's scheduled tasks (audit reminders, expected-checkin alerts, license expiry warnings) silently don't run
- new versions ship every 3-6 months — base-OS CVEs accrue between releases
This stack fixes all three:
- PHP 8.5 (upstream-supported via
composer.json^8.2) - Scheduler + queue worker run by default via ofelia (Netresearch's fork) and a dedicated
workerservice — no in-container cron, no silently-dropped emails - Daily rebuild picks up Alpine + PHP + Composer-dep patches without waiting for an upstream Snipe-IT release
Already running upstream snipe/snipe-it? See Migrating from the upstream single-container image.
git clone https://github.com/netresearch/snipe-it-docker-compose-stack.git
cd snipe-it-docker-compose-stack
make init # bootstraps .env (APP_KEY + random DB passwords, idempotent)
make up # docker compose up -d
make logs-app # Ctrl-C stops the tail (does NOT stop the stack)
# First boot pulls ~700 MB of images (~5 min on a typical link)
# and runs `php artisan migrate --force` (~60-90 s) — wait for the
# "ready — exec into CMD" line before opening the URL.
# Then open: http://localhost:8000For public deployments, set APP_URL in .env (no trailing slash) AFTER make init and run make down && make up to re-read env across all services (make restart only restarts app + web, so worker would keep emitting stale URLs in queued emails). The reverse-proxy overlay at examples/compose.traefik.yml handles TLS termination.
| Goal | Command |
|---|---|
| Start / stop the stack | make up / make down |
Restart app + web only (no env re-read) |
make restart |
| Tail logs | make logs-app (one service) or make logs (all) |
| Aggregated health (wire into monitoring) | make health |
| One-shot artisan command | make artisan CMD="route:list" |
| Interactive Laravel REPL | make tinker |
Shell inside app |
make shell |
| On-demand backup | make backup (ofelia auto-runs nightly at 03:00) |
| Pull + recreate + tail | make upgrade |
| Show enabled overlays | make overlays |
| Enable an overlay | make enable-traefik / make enable-caddy / make enable-observability / make enable-bugsink |
| Disable an overlay | make disable-traefik / make disable-caddy / make disable-observability / make disable-bugsink |
| Wire external Sentry/Bugsink | make enable-sentry DSN=https://... / make disable-sentry |
make help is the canonical, always-current list.
cp compose.override.yml.example compose.override.yml
docker compose up -dBrings up the same stack plus:
- mailpit at http://localhost:8025 — SMTP sink + web UI to catch outgoing notifications
- adminer at http://localhost:8081 — DB browser
- Exposed db (3306) + valkey (6379) host ports for external clients
APP_DEBUG=true,APP_ENV=local
The default web service binds plain HTTP on ${SNIPEIT_HTTP_PORT:-8000}. Front it with your TLS terminator of choice. Two overlays ship in examples/:
# Traefik (requires an existing traefik network)
docker compose -f compose.yml -f examples/compose.traefik.yml up -d
# Caddy
docker compose -f compose.yml -f examples/compose.caddy.yml up -dFor host-side nginx / HAProxy / your existing terminator, point it at 127.0.0.1:${SNIPEIT_HTTP_PORT} and set APP_URL=https://… in .env. A Prometheus-scraping overlay also ships at examples/compose.observability.yml.
Built daily, multi-arch (linux/amd64 + linux/arm64), in two variants. Pinned (default) honours Snipe-IT's composer.lock; rolling (suffix -rolling) re-resolves Composer ranges so CVE fixes in transitive deps land faster.
| Tag | Variant | Resolves to |
|---|---|---|
latest |
pinned | Latest stable Snipe-IT release (tracks .snipe-it-version) |
8.5.0 |
pinned | refs/tags/v8.5.0 |
8.5.0-YYYYMMDD |
pinned | Reproducible dated build — audit-friendly |
8.5 / 8 |
pinned | Auto-rolls on patch / minor bump |
master / develop / nightly |
pinned | Upstream branch HEAD |
master-rolling / develop-rolling / nightly-rolling |
rolling | Upstream branch HEAD with composer.lock deleted |
<tag>-rolling |
rolling | Same source ref as pinned, composer.lock deleted before install |
sha-pinned-<sha> / sha-rolling-<sha> |
both | Per-stack-commit build |
Pick pinned for production — reproducible, manifest-equivalent rebuilds (modulo base-image patches). Pick rolling if you'd rather catch transitive-dep CVEs early than match upstream's tested dependency graph.
Rolling-variant caveat: rolling builds can fail (and skip publishing) when upstream's composer.json references a major version of a dependency entirely covered by a Composer audit advisory — Composer refuses to install vulnerable packages by default. When this happens, the pinned image still ships because its lockfile pins the specific safe version upstream chose; the rolling tag lags until Snipe-IT bumps its constraint. Watch the failed rolling job in CI to see which advisory tripped it.
Each image (both variants) ships a manifest at /var/lib/snipeit/deps.txt — docker exec <container> cat /var/lib/snipeit/deps.txt to see exactly which versions are installed.
.env.example is the complete reference. Required vars:
| Variable | Description |
|---|---|
APP_KEY |
Laravel application key — generate once, never rotate |
APP_URL |
Public URL, no trailing slash |
DB_PASSWORD |
Application DB user password |
DB_ROOT_PASSWORD |
MariaDB root (only used at init) |
Operational toggles:
| Variable | Default | Description |
|---|---|---|
SNIPE_IT_IMAGE_TAG |
latest |
Pin to a specific image build |
CACHE_DRIVER / SESSION_DRIVER / QUEUE_CONNECTION |
redis |
Laravel driver names (RESP protocol). Flip to file/file/sync if you remove the valkey service |
SKIP_MIGRATIONS |
false |
Skip php artisan migrate --force at container start |
TZ |
UTC |
IANA timezone — sets the OS clock inside containers (log/cron timestamps) |
APP_TIMEZONE |
inherits TZ |
What Laravel reads for date/time display; override only if it must diverge from TZ |
SENTRY_LARAVEL_DSN |
(empty) | Error tracking DSN — empty disables; see Error tracking |
Docker secrets supported via *_FILE env vars (e.g. DB_PASSWORD_FILE=/run/secrets/db_password).
The image ships sentry/sentry-laravel pre-installed. Set SENTRY_LARAVEL_DSN to your project DSN (http(s)://<key>@<host>/<project-id>) to start collecting errors; an empty DSN keeps the SDK silent (no network calls). Bugsink is recommended for self-hosting — it speaks the Sentry wire protocol and avoids the SaaS data-egress concern for an internal asset-management tool.
| Goal | Command |
|---|---|
| Point at an external Sentry/Bugsink instance | make enable-sentry DSN=https://<key>@<host>/<project-id> |
| Run a self-hosted Bugsink in-stack with auto-wired DSN | make enable-bugsink |
| Stop external reporting | make disable-sentry |
| Remove the in-stack Bugsink overlay | make disable-bugsink |
| Show what's currently enabled | make overlays |
Both enable-* targets mutate .env; after enabling, make up brings the configured stack up — no manual -f chaining. The Bugsink overlay adds a SQLite-backed bugsink service plus a one-shot bugsink-init container that seeds a project and writes its DSN into a tmpfs-backed shared volume; app and worker pick it up via SENTRY_LARAVEL_DSN_FILE. Re-runs are idempotent.
When fronting Bugsink with TLS, set BUGSINK_PUBLIC_URL=https://bugsink.example.com and BUGSINK_BEHIND_HTTPS_PROXY=true — see the comments in examples/compose.bugsink.yml for the X-Forwarded-* trust caveat (Traefik strips client-supplied headers by default; Caddy needs trusted_proxies configured). BUGSINK_ADMIN_PASSWORD is a bootstrap-only value — rotate it via the Bugsink UI on first login, then clear it from .env.
- Non-root execution —
www-dataruns php-fpm and queue:work; entrypoint drops privileges withsu-execafter repairing volume permissions - No new privileges —
security_opt: no-new-privileges:trueon every long-running service (db/valkey/app/web/worker/scheduler/backup, plus the bugsink overlay) - Capability drop —
cap_drop: ALLonapp,web, andworkerwith minimal re-adds (seecompose.ymlfor the per-service list); db/valkey/scheduler/backup keep default caps the upstream images expect - Overlay parity — opt-in overlays in
examples/(Bugsink, Traefik, Caddy, observability) ship with the sameno-new-privileges+cap_drop: ALLposture on every long-running service they add - Read-only mounts — nginx reads
app-publicandapp-storageread-only - tmpfs —
/tmp,/var/cache/nginx,/var/runare tmpfs onweb;bootstrap/cacheis tmpfs onapp(prevents attacker-written PHP from persisting across restarts) - Unix-socket-only php-fpm — no TCP listener on 9000, so a sibling container on the snipeit network can't bypass nginx and speak FastCGI directly to
app - Pinned upstream — Snipe-IT git-tag-pinned via
.snipe-it-version; base PHP + Alpine versions tag-pinned inDockerfile(rebuilt daily so re-tagged registry content is picked up) - Daily rebuild — picks up base-image CVEs without waiting for upstream
- Supply chain — SLSA build provenance attestations (in-toto via
attest-build-provenance), SBOM (BuildKitsbom=true), and a keyless cosign signature on the image manifest (OIDC, Rekor-logged) — all attached on every push and scheduled build - CVE scanning — daily Trivy + osv-scanner runs (see Actions → security). Findings are informational, NOT CI gates — most flagged CVEs are in Snipe-IT's upstream-pinned
composer.lock(e.g.phpseclib,onelogin/php-saml) and need an upstream fix. Trivy SARIF uploads to GitHub code-scanning; subscribe via the repo Security tab for new-finding alerts.
phpbu runs nightly at 03:00 (ofelia-driven) and produces three artefact families in the backups volume:
| Path | Contents | Retention |
|---|---|---|
db/snipeit-db-*.sql.gz |
mariadb-dump (single-transaction) | rolling capacity (~5 GB) |
uploads/snipeit-uploads-*.tar.gz |
Snipe-IT uploads (app-data volume) |
30 days |
storage/snipeit-storage-*.tar.gz |
Laravel storage (app-storage volume) |
30 days |
On-demand backup: make backup. Off-host shipping: bind-mount the backups volume into a destination synced by your existing tool (restic, rclone, NAS-attached cron). Disaster-recovery procedure: docs/runbook-restore.md.
make upgrade # pulls latest images, recreates containers, follows logsThe app entrypoint runs php artisan migrate --force on every start. No DDL grant dance required — this stack's DB user is the app's own MariaDB account with full schema rights inside its database.
When something breaks, docs/runbook-day2-ops.md catalogues the failure modes we know about — symptom → first check → recovery. Common ones:
- App returns 500 —
make logs-app(Laravel logs to stdout as JSON viaLOG_CHANNEL=stderr+LOG_STDERR_FORMATTER) - Users randomly logged out — Valkey LRU eviction; tune
--maxmemoryincompose.ymlor switch toSESSION_DRIVER=file make upcomplains about missing.env— runmake initfirst; the Makefile guard prevents the empty-root-password footgun- Backup-volume full —
make backup-verifyflags it; tune retention inconfig/phpbu/backup.json
make health shows the aggregated health state of all containers and fails loudly when one is unhealthy — wire it into your existing monitoring.
If you're moving from snipe/snipe-it:vX.Y.Z-alpine (Apache + PHP-FPM + cron in one container), follow docs/migration-from-upstream-single-container.md. It covers volume mapping, env-var translation, and the cutover sequence (the upstream STORAGE_DIR and PRIVATE_UPLOADS_DIR aren't a 1:1 match for this stack's app-data / app-storage split).
- grokability/snipe-it — upstream Snipe-IT itself
- snipe/snipe-it — official Docker image
- netresearch/ofelia — the scheduler this stack uses
- netresearch/phpbu-docker — the backup engine this stack uses
PRs welcome. Standard community files (CONTRIBUTING / CODE_OF_CONDUCT / SECURITY) inherit from Netresearch's org-level .github repo.
Security issues: please report via the standard Netresearch security contact rather than as a public issue.
This repository uses split licensing — the right tool for each part:
| Path | License | Rationale |
|---|---|---|
Dockerfile, rootfs/, config/, compose*.yml, examples/, .github/, bin/, Makefile, tests/, renovate.json, .snipe-it-version |
MIT | Code and code-shaped configuration |
README.md, CHANGELOG.md, docs/** |
CC-BY-SA-4.0 | Prose and documentation — share-alike keeps forks open |
The built image (ghcr.io/netresearch/snipe-it-php-fpm:*) bundles AGPL-3.0 Snipe-IT application code from grokability/snipe-it. Redistribution of the image is bound by the upstream AGPL-3.0 terms in addition to MIT for our build glue.
This split follows the Netresearch skill-repo licensing pattern.