This project is Docker-first for operators who do not want to build Django or Vite locally.
Use the production Compose file when deploying published images:
Copy-Item .env.example .env
docker compose -f docker-compose.prod.yml up -dSet these values before first boot:
POSTGRES_PASSWORD=replace-with-a-strong-password
SECRET_KEY=replace-with-a-long-random-secret
ALLOWED_HOSTS=inventory.example.org
CORS_ALLOWED_ORIGINS=https://inventory.example.org
MINIO_ROOT_USER=replace-with-a-random-access-key
MINIO_ROOT_PASSWORD=replace-with-a-long-random-secret
AWS_S3_PUBLIC_ENDPOINT_URL=https://files.inventory.example.org
MINIO_CORS_ALLOWED_ORIGINS_JSON=["https://inventory.example.org"]
MAKERSPACE_IMAGE_TAG=latestOptional image overrides:
MAKERSPACE_BACKEND_IMAGE=ghcr.io/spaceworks-hq/spaceworks-backend
MAKERSPACE_FRONTEND_IMAGE=ghcr.io/spaceworks-hq/spaceworks-frontendUntil the GHCR images are published publicly, build locally with the build overlay:
docker compose -f docker-compose.prod.yml -f docker/compose.build.yml up -d --buildFor a guided first run that generates secrets and .env for you, use the setup.sh / setup.ps1
scripts at the repo root (see setup-for-makerspaces.md).
The frontend container's nginx proxies
/api/,/static/, and the docs routes to the backend. The single published port (80) serves the public app, the React staff console at/admin, and Swagger. The Django control plane is mounted at/control/on the backend and is intentionally not exposed on the public frontend port; access it only through direct backend access.
Create the first superadmin and makerspace:
docker compose -f docker-compose.prod.yml exec backend python manage.py setup_instance `
--username admin `
--email admin@example.org `
--password "replace-with-a-strong-password" `
--makerspace-name "My Makerspace"The command is idempotent. It creates missing records and upgrades the named user to a superadmin if needed.
Backend:
GET /api/v1/health/
GET /api/v1/health/readiness/
The Compose files include health checks for Postgres, backend readiness, and frontend HTTP serving.
Return reminder emails are sent by a management command. Schedule it every 15-60 minutes with cron, systemd timers, Windows Task Scheduler, or your hosting platform's scheduler:
docker compose -f docker-compose.prod.yml exec backend python manage.py send_return_remindersThe command is idempotent. It only sends reminders for issued or partially returned requests whose return_due_at is in the past and whose reminder has not already been sent. Requests returned before the due time are skipped.
Every successful push to main publishes matching backend/frontend images and a GitHub Release. The
release is marked latest only after both images are available. The self-host updater uses that release
as its gate, creates a PostgreSQL backup, deploys the exact immutable tag, runs migrations through the
Compose migration service, and records the version only after the readiness check passes.
Guided setup offers automatic checks every seven days by default. A Super Admin can then open Staff console -> Platform settings -> Software updates to turn automatic installation on or off, see the installed/latest versions, or queue Update now. The web application never receives Docker socket access: it records the request in PostgreSQL and the host scheduler performs the privileged work. Turning automatic installation off leaves the seven-day host check active, so release information and manual requests still work without installing anything automatically. Install or repair the schedule manually with:
bash scripts/install-auto-update.sh # macOS / Linux cron
powershell -ExecutionPolicy Bypass -File scripts/install-auto-update.ps1 # Windows Task SchedulerRun an immediate checked update with:
bash scripts/update.sh --force
powershell -ExecutionPolicy Bypass -File scripts/update.ps1 -ForcePre-update database dumps are written to backups/ and retained for 14 days. Each compressed dump
contains PostgreSQL data: users, settings, inventory, requests, loan history, and audit metadata. It does
not contain MinIO objects such as evidence photos, machine images, documents, or print files; back up
the minio_data volume separately. The database snapshot is a recovery point and is never restored
automatically.
The scripts use .spaceworks-update.lock to prevent overlapping runs and .spaceworks-version to avoid
redeploying the same release. If migration, deployment, or readiness fails after replacement starts, the
updater automatically pulls and starts the previous retained application release, then verifies its
health. The version marker is not advanced, the UI records whether rollback succeeded, and the database
backup remains available. Database migrations are not reversed automatically; keep migrations backward
compatible with the immediately previous application release. If application rollback also fails, review
backups/auto-update.log before restoring the database snapshot and previous image tag manually.
For a manual deployment, set MAKERSPACE_IMAGE_TAG to a release such as
0.5.1-main.42.a1b2c3d4e5f6, then run:
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -dDo not schedule a blind container watcher: application images must not restart without the migration
service and readiness gate. Manual dependency audit: pip install pip-audit && pip-audit -r backend/requirements.txt.
Every push to main runs release.yml, publishes matching backend and frontend images, and creates a
GitHub Release titled with the version from VERSION (for example, v0.5.1). Its internal tag still
identifies the exact build used by the updater. When both images succeed for the current branch head, the workflow promotes
them to the rolling :X.Y, :main, and :latest tags, then removes older Releases and GHCR versions.
The current and immediately previous builds remain available so a failed deployment can roll its
application containers back automatically.
The root VERSION file selects the semantic release series. Edit it (for example, to 1.0.0) only
when starting a new series; the workflow adds the run number and commit SHA to every release automatically.
The spaceworks-backend / spaceworks-frontend GHCR packages must be set to Public (org → Packages)
so operators can docker compose pull without authenticating.
TLS-dependent settings are env-gated, not DEBUG-gated, so the default HTTP stack works out of
the box. The default frontend nginx does not trust inbound X-Forwarded-Proto; it forwards only
its own scheme to Django.
For a real domain with automatic TLS, use the Caddy overlay:
PUBLIC_DOMAIN=inventory.example.org
CSRF_TRUSTED_ORIGINS=https://inventory.example.org
AWS_S3_PUBLIC_ENDPOINT_URL=https://files.inventory.example.org
MINIO_CORS_ALLOWED_ORIGINS_JSON=["https://inventory.example.org"]docker compose -f docker-compose.prod.yml -f docker/compose.tls.yml --profile tls up -dThe overlay enables ENABLE_HTTPS=true and TRUST_X_FORWARDED_PROTO=true for the backend. Caddy is
then the trusted TLS boundary: /api, /static, and docs paths go directly to Django with
X-Forwarded-Proto: https, while the React app goes to the frontend container. Keep any direct
backend/frontend HTTP ports private when the TLS overlay is active.
Always-on protections (any transport): django-axes locks out brute-force admin logins
(AXES_FAILURE_LIMIT, keyed by ip+username), a scoped throttle limits the JWT login endpoint, the
public submit endpoint has its own anti-spam throttle + a honeypot, and a Content-Security-Policy is
sent on every response. The Django control plane at /control/ is restricted to active
superusers only and must be reached through direct backend access, never through the public
frontend port.
Secrets (SECRET_KEY, API_CLIENT_ENC_KEY, makerspace Telegram bot tokens, makerspace SMTP
passwords) live only in the backend. API_CLIENT_ENC_KEY is the Fernet key that encrypts the
per-makerspace integration secrets at rest — back it up and do not rotate it casually, or
previously stored tokens/passwords can no longer be decrypted.
On a self-hosted instance (the default — PLATFORM_DOMAIN_SUFFIX is blank) you own both DNS and
the server, so a makerspace's custom domain is trusted the moment a superadmin sets it — there is
no DNS TXT challenge. (The TXT-verification flow only exists to defend the shared managed space-works.tech
box; it stays dormant here.) End-to-end:
-
Point DNS at the server. Create an
A/AAAArecord for the hostname (for exampletools.example.org) pointing at this deployment's public IP. -
Enable automatic HTTPS. Staff login on a custom domain requires HTTPS — the staff-auth allowlist only trusts the
https://origin (localhost dev is the solehttpexception). Set the TLS env and bring up the Caddy overlay:PUBLIC_DOMAIN=tools.example.org CSRF_TRUSTED_ORIGINS=https://tools.example.org # Django's own host check (CommonMiddleware) is separate from the tenant host # middleware — add every custom hostname here or requests 400 with DisallowedHost. ALLOWED_HOSTS=localhost,127.0.0.1,tools.example.org
docker compose -f docker-compose.prod.yml -f docker/compose.tls.yml --profile tls up -d
Caddy (
deploy/Caddyfile) terminates HTTPS and forwards both the public site and the/adminstaff console to this deployment. -
Set the domain in Settings. As a superadmin, open the makerspace's Settings → Custom domain, enter the hostname, and Save. It shows Active immediately (no TXT record, no Verify step). Only a superadmin may set it — the staff-auth/CORS allowlist is process-global (not tenant-scoped), so on a multi-makerspace box an untrusted Space Manager must never be able to inject a globally-trusted origin. This holds even for a makerspace hidden from the superadmin (
superadmin_access_enabled=False): to set its domain, have its Space Manager re-enable superadmin access, set the domain as the superadmin, then re-hide it. -
Point the branded frontend at this backend. Set the frontend container's
TENANT_ORIGIN_BOOTSTRAP=true(resolve the makerspace by request origin) orTENANT_TOKEN=<public_code>, plusTENANT_API_URL=/api. See single-tenant-frontend.md.
If an instance flips from managed → self-host after deploy, run
python manage.py reconcile_selfhost_domains once to promote any existing custom domains to trusted
(the migration does this automatically on a fresh self-host deploy).
| Variable | Required | Purpose |
|---|---|---|
POSTGRES_PASSWORD |
yes | Database password (also used to build DATABASE_URL) |
SECRET_KEY |
yes | Django cryptographic secret |
ALLOWED_HOSTS |
yes | Comma-separated hostnames the backend will serve |
DATABASE_URL |
no | Overrides the default Postgres URL (e.g. point at Supabase) |
CORS_ALLOWED_ORIGINS |
no | Browser origins allowed to call the API |
API_CLIENT_ENC_KEY |
recommended | Fernet key encrypting integration secrets at rest |
MINIO_ROOT_USER |
yes | MinIO/S3 access key used by the backend |
MINIO_ROOT_PASSWORD |
yes | MinIO/S3 secret key used by the backend |
AWS_STORAGE_BUCKET_NAME |
no (default evidence) |
Private object-storage bucket for evidence and print files |
PUBLIC_IMAGE_BUCKET |
no (default public-images) |
Anonymous-read bucket for public item photos and makerspace logo/cover images |
PUBLIC_IMAGE_BASE_URL |
yes for public images | Browser public base URL for PUBLIC_IMAGE_BUCKET (for example https://files.inventory.example.org/public-images) |
PUBLIC_IMAGE_MAX_BYTES |
no (default 5242880) |
Maximum public image upload size |
PUBLIC_IMAGE_URL_TTL_SECONDS |
no (default 300) |
Presigned upload URL lifetime for public images |
AWS_S3_ENDPOINT_URL |
no (default http://minio:9000) |
Backend-to-MinIO endpoint inside Compose |
AWS_S3_PUBLIC_ENDPOINT_URL |
yes for uploads | Browser-reachable MinIO/S3 endpoint used in presigned URLs |
MINIO_CORS_ALLOWED_ORIGINS_JSON |
yes for uploads | JSON array of frontend origins allowed to POST/GET objects |
ENABLE_HTTPS |
no (default false) | Turns on SSL redirect, Secure cookies, HSTS |
TRUST_X_FORWARDED_PROTO |
no (default false) | Trusts X-Forwarded-Proto only for the TLS proxy overlay |
CSRF_TRUSTED_ORIGINS |
when HTTPS | https:// origin(s) trusted for login POSTs |
AXES_FAILURE_LIMIT |
no (default 5) | Failed admin logins before lockout |
HTTP_PORT |
no (default 80) | Published frontend port |
EMAIL_*, DEFAULT_FROM_EMAIL |
no | Global fallback SMTP (per-makerspace SMTP overrides it) |
MANAGED_POSTGRES |
no (default False) |
True on managed Postgres (Supabase): purge suspends immutability triggers via a custom GUC instead of session_replication_role (which needs superuser) |
CONN_MAX_AGE |
no (default 0) |
Persistent DB connection lifetime; keep 0 on the Supabase transaction pooler |
DISABLE_SERVER_SIDE_CURSORS |
no (default False) |
Set True on the Supabase transaction pooler (no server-side cursors) |
STORAGE_PRESIGN_METHOD |
no (default post) |
put for Supabase Storage presigned PUT uploads (server re-validates size at attach) |
CRON_SECRET |
no (default empty) | Enables POST /api/v1/internal/cron/return-reminders (header X-Cron-Secret); 404s while unset |
Supabase free-tier deployment (managed Postgres + Storage, env-toggled, demo/pilot scope): see supabase-deployment.md for the full runbook. All five vars above default to the self-hosted behavior, so this Compose stack is unaffected unless you set them.
Production Compose includes MinIO because the backend stores evidence photos and 3D-print files in
S3-compatible object storage by default. The backend talks to MinIO at http://minio:9000; browsers
use AWS_S3_PUBLIC_ENDPOINT_URL in presigned upload/download URLs, so that value must be reachable
from staff/requester browsers.
Public catalog images use a separate PUBLIC_IMAGE_BUCKET that is anonymous-readable by design.
The bundled Compose bootstrap creates it, sets download policy, and applies the same upload CORS
policy. Keep evidence and print files in the private AWS_STORAGE_BUCKET_NAME; only item photos and
makerspace logo/cover images belong in the public bucket.
For HTTPS deployments, put MinIO behind the same TLS proxy as the frontend, for example:
AWS_S3_PUBLIC_ENDPOINT_URL=https://files.inventory.example.org
MINIO_CORS_ALLOWED_ORIGINS_JSON=["https://inventory.example.org"]If you expose MinIO directly on a LAN during a local pilot, set AWS_S3_PUBLIC_ENDPOINT_URL to the
server address and port that browsers can reach, for example http://192.168.1.20:9000. The MinIO
console binds to 127.0.0.1:9001 by default; keep it private or put it behind authenticated VPN/admin
access.
Operational data lives in Postgres and object files live in the minio_data Docker volume. Back up
both before upgrades:
docker compose -f docker-compose.prod.yml exec -T db \
pg_dump -U makerspace makerspace_manager > backup-$(date +%F).sql
mkdir -p backups
docker compose -f docker-compose.prod.yml run --rm --entrypoint sh \
-v "$PWD/backups:/backup" \
minio \
-c 'tar -czf /backup/minio-$(date +%F).tgz -C /data .'Also keep a copy of your .env (it holds API_CLIENT_ENC_KEY, without which encrypted integration
secrets are unrecoverable, and the MinIO credentials needed to read object backups).
Restore order is database first, then object files. Stop the stack, restore the Postgres dump into the
db service, unpack the MinIO archive into the minio_data volume, then start the stack and check
/api/v1/health/readiness/.
One backend can serve many makerspaces. A makerspace without its own server can be hosted as an
additional tenant on another makerspace's instance — each tenant gets its own makerspace record,
public URL/slug, branding, and (optionally) its own branded domain, all isolated by makerspace
scoping. To give a tenant its own branded site, set its Custom domain
(Makerspace.frontend_domain) in the staff console Settings tab; that single field drives CORS,
bootstrap resolution, and the staff-auth allowlist. See docs/single-tenant-frontend.md.
Browser frontends must use publishable configuration only. Do not place HMAC secrets in JavaScript bundles.
Use GET /api/v1/bootstrap?tenant=<public-code> or GET /api/v1/bootstrap?slug=<makerspace-slug>
(or simply serve the branded site from its frontend_domain, which bootstrap resolves by origin) to load:
- makerspace identity
- enabled modules and workflows
- theme and branding
- publishable public API hints
The React public and staff frontends use enabled_modules as live navigation gates. If a tenant does
not see a workflow, check the makerspace module flags first: self_checkout gates public
self-checkout and staff direct handout, printing gates 3D-printing workflows, stocktake gates
stocktake, containers gates container tools, qr_management gates QR tools/scanner, and reports
appear for reports or printing-related workflows.
A makerspace's frontend_domain and its cors_allowed_origins (API-client origins) are used for
per-tenant browser access; only the frontend_domain origin may hold a staff session.