3D Vault is a self-hosted, open-source web application that turns a grown, unsorted 3D-printing file folder into a searchable, visual catalog and covers the entire path from file to finished print - automatic ingestion, previews, organization, duplicate detection, a virtual print bed, PrusaSlicer integration, printer connectivity, and an optionally public print queue for shared printers (e.g. in a hackspace).
  • PHP 55.2%
  • Vue 35.6%
  • Python 6.1%
  • Shell 1%
  • JavaScript 0.8%
  • Other 1.2%
Find a file
2026-09-19 15:31:28 +02:00
app initial files for 3dvault 2026-09-19 15:31:28 +02:00
bootstrap initial files for 3dvault 2026-09-19 15:31:28 +02:00
config initial files for 3dvault 2026-09-19 15:31:28 +02:00
database initial files for 3dvault 2026-09-19 15:31:28 +02:00
deploy initial files for 3dvault 2026-09-19 15:31:28 +02:00
driver-runner initial files for 3dvault 2026-09-19 15:31:28 +02:00
lang initial files for 3dvault 2026-09-19 15:31:28 +02:00
public initial files for 3dvault 2026-09-19 15:31:28 +02:00
render-worker initial files for 3dvault 2026-09-19 15:31:28 +02:00
resources initial files for 3dvault 2026-09-19 15:31:28 +02:00
routes initial files for 3dvault 2026-09-19 15:31:28 +02:00
slicer-worker initial files for 3dvault 2026-09-19 15:31:28 +02:00
storage initial files for 3dvault 2026-09-19 15:31:28 +02:00
tests initial files for 3dvault 2026-09-19 15:31:28 +02:00
.editorconfig initial files for 3dvault 2026-09-19 15:31:28 +02:00
.env.example initial files for 3dvault 2026-09-19 15:31:28 +02:00
.gitattributes initial files for 3dvault 2026-09-19 15:31:28 +02:00
.gitignore initial files for 3dvault 2026-09-19 15:31:28 +02:00
.npmrc initial files for 3dvault 2026-09-19 15:31:28 +02:00
artisan initial files for 3dvault 2026-09-19 15:31:28 +02:00
composer.json initial files for 3dvault 2026-09-19 15:31:28 +02:00
composer.lock initial files for 3dvault 2026-09-19 15:31:28 +02:00
LICENSE initial files for 3dvault 2026-09-19 15:31:28 +02:00
package-lock.json initial files for 3dvault 2026-09-19 15:31:28 +02:00
package.json initial files for 3dvault 2026-09-19 15:31:28 +02:00
phpunit.xml initial files for 3dvault 2026-09-19 15:31:28 +02:00
README.de.md initial files for 3dvault 2026-09-19 15:31:28 +02:00
README.md initial files for 3dvault 2026-09-19 15:31:28 +02:00
vite.config.js initial files for 3dvault 2026-09-19 15:31:28 +02:00

3D Vault

3D Vault is a self-hosted, open-source web application (GPL-3.0-or-later, see LICENSE) that turns a grown, unsorted 3D-printing file folder into a searchable, visual catalog and covers the entire path from file to finished print — automatic ingestion, previews, organization, duplicate detection, a virtual print bed, PrusaSlicer integration, printer connectivity, and an optionally public print queue for shared printers (e.g. in a hackspace).

What Is 3D Vault For?

Anyone who has collected 3D models for years — downloaded Thingiverse/Printables finds, their own designs, OpenSCAD scripts, Blender scenes, mixed in with assembly instructions and photos — almost inevitably ends up with a chaotic folder full of subfolders, duplicate files, version names like -NEW2, and formats that no ordinary file browser can preview sensibly. 3D Vault solves exactly this problem: a background scanner catalogs the entire inventory automatically, generates thumbnails and interactive 3D views, detects duplicates and revisions, and turns it into a searchable gallery with tags, projects, and full-text search — without touching the original files unless you explicitly want it to.

Beyond that, 3D Vault is not just a catalog — it covers the entire printing workflow: models can be placed directly from within the app onto a virtual multi-part print bed, given infill/support settings, sliced to G-code via PrusaSlicer, and sent to a connected printer — including live status polling and automatic print-log reconciliation. An optional print queue, usable anonymously as well, additionally makes the app suitable for shared infrastructure such as a hackspace: multiple users, multiple printers, no account requirement for submitting a print request.

What 3D Vault Does

Cataloging & Organizing: recursive scan of the source folder, metadata (path, size, modification date, content hash), automatic detection of Thingiverse/Printables download structures (including license/source extraction) and of revision/backup patterns in filenames. Plus tags, projects/collections, a two-stage trash (soft delete + permanent delete), and move/copy within the inventory.

Visualizing: automatically generated thumbnails and an interactive 3D viewer (Three.js) for every supported file; image files are displayed directly instead of rendered; formats that can't be displayed show a format badge instead of a preview image but don't disappear from the catalog.

Finding Duplicates: exact duplicates via content hash, "probably the same model" via geometry fingerprint — robust against different exporters, repeated downloads, or slightly different scaling.

Searching: fuzzy full-text search via Meilisearch, format/project/tag/status filters, path/folder search.

Converting: selected files in several non-STL formats can be converted to a print-ready STL with a click on the detail page, to make them usable in the slicer (see Supported File Formats below). This never runs automatically in the background, but is always a deliberate, individual action per file — otherwise every .blend file in the entire inventory would get converted along with the next scan, which for a historically grown archive (in which far from every .blend file is even a print model) would be neither desired nor cheap.

Slicing: virtual multi-part print bed (up to 12 parts), free positioning via drag & drop, rotation and resizing per axis, infill/support configuration, real mesh-vs-mesh collision checking, PrusaSlicer integration with embedded G-code preview thumbnails.

Printing: printer profiles with isolated driver plugins (currently Creality WebSocket and OctoPrint, see Driver Plugins), G-code upload and print start directly from the app, live progress polling, automatic print-log reconciliation.

Sharing: an optional, publicly accessible print queue per printer for hackspace/multi-user scenarios, with its own security measures for anonymous uploads.

Securing: a complete login layer with optional 2FA (TOTP), roles (admin/user via an is_admin group flag), all deleting actions restricted to admins without exception.

Localizing: the entire interface is available in English (default) and German, negotiated automatically per request from the browser's Accept-Language header — no manual language switcher, no account setting, nothing to configure.

Table of Contents

Self-Hosting: Setup

3D Vault runs entirely on Docker Compose. This section walks you through everything once, from an empty server to your first successful login — everything after that (setting up printers, browsing files, slicing, …) builds on a running instance and is explained in the following sections.

Prerequisites

  • A Linux host with Docker and the Docker Compose plugin (docker compose version should work — not the older standalone docker-compose).
  • An existing folder with your 3D-printing file inventory (STL, OBJ, Blender files, …) — may be empty but must exist and be writable by PUID:PGID (see Source Mount). 3D Vault does not create this folder itself — if it's missing or the permissions don't match, docker compose up refuses to start, or app fails with a clear error message instead of a silent Permission denied on first file access.
  • Optionally your own Traefik reverse proxy with TLS, if your instance should be publicly reachable under a real domain name. Without Traefik, 3D Vault works just as well over a directly published port (see "Step 2" below) — useful for a first test or a pure LAN setup.

Step 1: Clone and Configure the Repository

git clone <repo-url> 3dvault
cd 3dvault/deploy
cp .env.example .env

Now open deploy/.env in an editor (the file has an explanatory comment for every variable) and fill in at least the following required fields:

  • APP_KEY (generate with openssl rand -base64 32, enter it with the base64: prefix)
  • APP_URL
  • VAULT_SOURCE_PATH (the already existing path to your 3D-printing folder from above)
  • VAULT_DB_*
  • MEILISEARCH_KEY

A complete description of all variables — including the optional ones — can be found in the next section, Environment.

Step 2: Choose an Operating Mode and Start the Containers

3D Vault supports two equivalent operating modes, each activated via an additional Docker Compose file layered on top of the shared base (docker-compose.yml):

  • Standalone — no reverse proxy of your own needed. The app container publishes a port directly on the host (default http://localhost:8080, configurable via VAULT_HTTP_PORT). The right entry point if you're unsure, for a first test, or for a pure LAN setup.
  • Traefik — for running behind an already existing Traefik reverse proxy with its own domain name and TLS. Additionally requires VAULT_DOMAIN/TRAEFIK_* in deploy/.env.
# Standalone — app reachable directly via a host port (default http://localhost:8080):
docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d --build

# With Traefik — additionally set VAULT_DOMAIN/TRAEFIK_* in deploy/.env:
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --build

The first start takes noticeably longer than later restarts — Composer and npm install/build run once (see Deployment for details on what happens in the background). Migrations and caches run automatically along with it, you don't need to trigger anything manually.

docker compose ps shows you once all containers are healthy. If your VAULT_SOURCE_PATH already contains files, the app container automatically triggers an initial catalog scan on its very first start (files:scan-if-new, see Scan Pipeline) — this runs in the background via the queue, so for larger inventories it can take a few seconds to minutes after "healthy". Without this automatic scan, the dashboard/gallery would otherwise stay empty for up to an hour (the watcher only reacts to new changes, not to the already existing inventory; the hourly fallback scan only runs at the next full hour). This does not happen again on any subsequent, ordinary restart — only a genuinely empty files table triggers it.

Step 3: Create an Admin Account and Log In

docker compose exec app php artisan app:ensure-admin-user --email=<your-address>

The command prints the email address and the randomly generated password directly and unmissably to the console (a highlighted yellow block) — this is the guaranteed way to get the credentials, regardless of whether or how SMTP is configured. The password is never stored in plaintext anywhere and is shown exactly once, right here — copy/note it down on the spot.

The command additionally attempts to send the same credentials by mail — but that only runs as an optional second channel you don't need to rely on. Without real SMTP configuration (MAIL_MAILER defaults to log), that second channel doesn't arrive by mail but ends up in its own log file instead:

docker compose exec app cat storage/logs/mail.log

(not storage/logs/laravel.log — the general log deliberately runs at LOG_LEVEL=warning in production, but mail sending always writes at debug level; config/mail.php/config/logging.php therefore route the log mailer into its own, always-active mail channel. AccountCreated does implement ShouldQueue — but app:ensure-admin-user deliberately sends it synchronously via notifyNow() inside the app container itself instead of putting it on the queue, so that the cat command right after is guaranteed to find something instead of racing against a queue job that hasn't been processed yet. Only the web-based path via /admin/users queues the mail normally, so as not to block the HTTP request.)

Log in on APP_URL with the email address and the password shown on the console — the first login immediately forces a password change. After that, the instance is ready to use.

MAIL_FROM_ADDRESS has a working default (noreply@example.com) and doesn't need to be touched for this minimal scenario — but don't set it to an empty string: Symfony Mailer always requires a From header, even with the log mailer, otherwise mail sending (including this first credentials mail) fails with a LogicException and the job ends up in failed_jobs after 3 attempts, without the password ever becoming visible anywhere.

What Now?

Your instance is running and you're logged in. A sensible next stop is the Feature Overview — it shows compactly everything 3D Vault can do, with links to the respective detail sections. If you want to print right away, continue in the Printers and Slicer Profiles section.

Environment

deploy/.env.example is the complete, up-to-date template for deploy/.env (not tracked in Git) — all variables that docker-compose.yml and the two overlay files expect via ${...}, with comments right in the file. This README table is only a compact overview now; deploy/.env.example remains the primary source of truth.

Separately, there's a second .env.example at the repo root — it's exclusively for local development without Docker (php artisan serve, local tests with SQLite) and has nothing to do with running under Docker. entrypoint.sh doesn't touch it; every value that actually matters for operation comes as a container environment variable directly from deploy/.env via docker-compose.yml — including APP_LOCALE/APP_FALLBACK_LOCALE (en), the base locale used outside a request context (queue workers, Artisan commands). For Docker operation you only ever edit deploy/.env; you can completely ignore the root .env.example.

Important variables (required marked with *):

Variable Purpose
APP_KEY* Laravel app key
APP_URL* Full URL under which the app is reachable
VAULT_SOURCE_PATH* Host path to the 3D-printing file inventory (source mount)
PUID, PGID UID/GID for Composer/npm/Artisan inside the containers (default 1000:1000). Determines the owner of all files created by the container in the bind mount (vendor/, node_modules/, public/build/, storage/, ...). Determine your own values with id -u/id -g on the host — otherwise, mismatched values can mean you can only edit/delete these files with sudo
VAULT_DB_NAME, VAULT_DB_USER, VAULT_DB_PASSWORD, VAULT_DB_ROOT_PASSWORD MariaDB
MEILISEARCH_KEY* Meilisearch master/API key
VAULT_DOMAIN Only with docker-compose.traefik.yml: hostname for the Traefik Host() rule
TRAEFIK_NETWORK, TRAEFIK_CERTRESOLVER, TRAEFIK_ENTRYPOINT Only with docker-compose.traefik.yml: name of the external proxy network, ACME cert resolver, HTTPS entrypoint — adapt to your own Traefik configuration
VAULT_HTTP_PORT Only with docker-compose.standalone.yml: host port without Traefik (default 8080)
SESSION_SECURE_COOKIE true only with real TLS in front, otherwise false. Also controls (AppServiceProvider) whether the app enforces https URLs — leave at false in standalone operation without Traefik, otherwise redirects (e.g. guest → /login) point to https:// even though no TLS is being terminated, and the browser gets a TLS error instead of the login page. Also controls (bootstrap/app.php) whether trustProxies() is active at all — only true (Traefik operation) lets Laravel trust forwarded headers, see Security
RENDER_WORKER_URL URL of the render worker, normally http://render:8000
SLICER_WORKER_URL URL of the slicer worker, normally http://slicer:8000
DRIVER_RUNNER_URL URL of the isolated driver plugin runner, normally http://drivers:8000 — see Driver Plugins
SOURCE_PATH Source mount path in the app container, always /data/source
APP_LOCALE, APP_FALLBACK_LOCALE Fixed to en — not configurable via .env, set directly in docker-compose.yml. Only the base locale outside a request (queue workers, Artisan commands); the UI itself negotiates English/German per request from the browser's Accept-Language header, see Localizing
HASH_DRIVER Password hashing algorithm, argon2id
SESSION_LIFETIME Session/remember-me validity in minutes, default 43200 (30 days)
SESSION_ENCRYPT true — session payload in Redis is stored encrypted
MAIL_MAILER, MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_FROM_ADDRESS SMTP access, used for account creation emails (default MAIL_MAILER=log — without real configuration the generated password only ends up in the log). MAIL_FROM_ADDRESS has a working default (noreply@example.com) — do not set it empty, Symfony Mailer always requires a From header, even with the log mailer, otherwise every mail send fails

Volumes and File Paths

Source Mount

The 3D-printing inventory is mounted into the containers as:

/data/source

Host path: configurable via VAULT_SOURCE_PATH in deploy/.env (required variable, no default) — the folder with your own 3D-printing file inventory, must already exist.

Must be writable by PUID:PGID (delete/move/copy, print-queue uploads all go through the app container, which internally runs as www-data with this UID/GID). Easiest way: chown -R <PUID>:<PGID> <VAULT_SOURCE_PATH>. If the folder comes from a different source with its own owner (NAS mount, another user's sync client, etc.), group write permission + setgid works as an alternative (chgrp -R <PGID> <path> && chmod -R g+rwX <path> && chmod g+s <path-subfolder>) — important: if PGID (or the default) changes later, this permission has to be re-aligned, otherwise all writing actions fail with UnableToCreateDirectory/"Permission denied" even though the container itself is healthy.

Mount modes:

  • app: rw
  • queue: ro
  • scheduler: ro
  • watcher: ro
  • render: ro
  • slicer: ro

This is intentional. Writing file operations against the source inventory only ever go through authenticated (or, for the print queue, deliberately allowed anonymous) web requests in the app container. Workers only read.

Print-queue uploads land under their own prefix print-queue/{printer-key}/{uuid}/ inside the same source mount — see Print Queue.

Persistent Data

Path Content
deploy/db MariaDB data
deploy/redis Redis dump
deploy/meilisearch Meilisearch data
deploy/logs additional Apache access log
storage/app/public/renders Blender thumbnails, topdown thumbnails, GLB files, JSON metadata
storage/app/public/gcode-thumbnails bed thumbnails from the slicer worker
storage/app/private/gcodes generated G-code files

Backups

Three things are enough for a complete backup:

  • deploy/.env (without this file, the rest is no longer decryptable/usable — it contains APP_KEY, which among other things Printer::driver_config is encrypted with in the database)
  • deploy/db (the complete MariaDB data directory copy: catalog, tags, projects, printers, slicer profiles, driver plugins, users)
  • Your source folder (VAULT_SOURCE_PATH) — that's your actual 3D files. 3D Vault only writes there when you do it via the app (delete/move/copy/print queue); a separate, independent backup of this folder remains your responsibility regardless — 3D Vault does not replace a file backup.

Not strictly necessary to back up, because it can be recreated from the two sources above at any time:

  • deploy/redis (only cache/queue/sessions)
  • deploy/meilisearch (search index, rebuildable via php artisan scout:import "App\Models\File")
  • storage/app/public/renders, storage/app/public/gcode-thumbnails, storage/app/private/gcodes (thumbnails/GLBs/G-code — all reproducible from the original files or a re-run of slicing, but losing them loses convenience/history, not data loss in the strict sense)

A simple approach: briefly stop the app container (prevents write access during copying), back up deploy/.env and deploy/db, then start it again. For uninterrupted backups, a regular mariadb-dump against the running db container is more suitable instead.

Networks and Access

Docker networks:

  • internal: internal bridge network for app, DB, Redis, workers, Meilisearch — always present.
  • proxy: external Traefik network — only exists if docker-compose.traefik.yml is included (network name configurable via TRAEFIK_NETWORK, default proxy).

Only the app container is additionally attached to the proxy network. The render and slicer workers are not published via Traefik.

Traefik labels (in docker-compose.traefik.yml, values configurable via TRAEFIK_CERTRESOLVER/ TRAEFIK_ENTRYPOINT/TRAEFIK_NETWORK):

  • Main router 3dvault: host rule Host(${VAULT_DOMAIN}), entry point ${TRAEFIK_ENTRYPOINT:-websecure}, TLS cert resolver ${TRAEFIK_CERTRESOLVER:-letsencrypt}, middleware chain 3dvault-secheaders.
  • Additional router 3dvault-login: the same host rule plus PathPrefix on /login, /two-factor-challenge, /forgot-password, /reset-password; middleware chain 3dvault-secheaders,3dvault-loginlimit.
  • 3dvault-secheaders: HSTS (stsSeconds=31536000, stsIncludeSubdomains, forceSTSHeader), contentTypeNosniff, customFrameOptionsValue=SAMEORIGIN, referrerPolicy=strict-origin-when-cross-origin.
  • 3dvault-loginlimit: ratelimit with average=1, burst=5, period=1s — in addition to Fortify's application-side rate limiting (see below).
  • 3dvault-queuelimit: ratelimit with average=2, burst=10, period=1s on the print-queue router (PathPrefix /druckwarteschlange) — see Print Queue.

By default, the app is publicly reachable, secured primarily via its own login. Anyone who additionally wants an IP allowlist at the Traefik level can add and reference an ipallowlist middleware at any time in their own copy of docker-compose.traefik.yml.

Feature Overview

Compact reference of all features once your instance is running — the following sections provide details on each point.

Specifically, the app supports:

  • Scanning the source mount /data/source
  • File metadata: path, name, folder, extension, size, mtime, hash
  • README/LICENSE/platform detection for download folders
  • Revision/backup detection, e.g. .blend1, -NEW, -V2
  • Thumbnails and GLB previews via Blender
  • Topdown thumbnails for bed previews
  • Duplicate detection via content hash and geometry hash
  • Targeted STL conversion of individual files (.obj/.ply/.blend/.blend1/.scad), never automatic during scanning
  • Gallery with search, format, project, tag, and status filters
  • Tags and projects
  • Trash with restore and permanent deletion
  • Print logs, manual and automatic
  • Slicer UI with a virtual print bed, usable for all config('slicer.supported_extensions') formats, not just .stl
  • Multi-part beds with up to 12 parts
  • Infill/support options
  • Printer profiles for Ender 5 Max and Ender 5 S1
  • G-code download
  • G-code upload and print start
  • Live printer status and progress polling
  • Login with optional 2FA (TOTP), no self-signup
  • Admin backend with user, group, printer, slicer-profile, and singleton-settings management
  • Deleting actions (all variants) are restricted to admin users
  • Print queue per printer (STL upload with note/quality/infill wish), optionally usable publicly without login (admin toggle)

Supported File Formats

3D Vault distinguishes between three levels of format support — not every cataloged format can automatically be visualized, let alone printed:

Format Cataloged Preview (thumbnail + 3D viewer) Convertible to STL Usable in the slicer/plater
.stl (already STL) always, regardless of render status
.obj after targeted conversion
.ply after targeted conversion
.blend after targeted conversion
.blend1 (Blender's auto-backup) after targeted conversion
.scad (OpenSCAD source code) (via OpenSCAD→STL) after targeted conversion
Image files (png/jpg/jpeg/gif/webp/bmp/tif/tiff/svg) (original file, no rendering needed)
.3mf
.step/.stp
everything else (.txt, .pdf, .docx, .zip, Fritzing files, …) (format badge)

Everything gets cataloged, regardless of format — even non-3D files such as assembly plans, license texts, or photos show up in the inventory, just without a preview. Details on the render and conversion pipeline: see Render Pipeline and Targeted STL Conversion; for usage in the slicer, see Slicer UI.

Deliberate format limits: .3mf would need an additional Blender add-on (not installed), .step/.stp are not meshes but parametric B-rep CAD data and would need a real CAD kernel (OpenCASCADE/FreeCAD) — neither is currently implemented, see Known Functional Limits. Independently of this, the public Print Queue exclusively accepts .stl uploads (a security boundary for anonymous submissions, not a technical format limit).

Authentication and Admin Backend

Login

  • Backend: Laravel Fortify headless (laravel/fortify), with custom Inertia/Vue views instead of Fortify's default views.
  • Activated Fortify features (config/fortify.php): resetPasswords(), updatePasswords(), twoFactorAuthentication() (with confirm and confirmPassword). registration() and emailVerification() are deliberately disabled — there is no self-signup, only the admin creates users.
  • Password hashing: Argon2id (config/hashing.php, 64 MiB / 4 iterations / 2 threads), switchable via HASH_DRIVER.
  • Password rule (app/Providers/AppServiceProvider.php): at least 9 characters, upper- and lowercase letters plus numbers required, no special-character requirement. Additionally, uncompromised() checks outside the test environment against the Have I Been Pwned leak database (k-anonymity, only a SHA1 prefix leaves the server).
  • Rate limiting is doubly secured: Fortify limits login attempts per email+IP combination and additionally per IP across all email addresses; Traefik additionally limits /login and the other auth routes at the network level (3dvault-loginlimit, see above).
  • Session: SESSION_LIFETIME=43200 (30 days). The remember-me cookie ("stay logged in", enabled by default in the login form) actually has its own, independent Laravel default of 400 days — this is explicitly coupled to the same 30-day value in app/Providers/AppServiceProvider.php via setRememberDuration(), so login validity is genuinely capped at 30 days everywhere.
  • 2FA (TOTP) is optional for every user, set up under /profile: QR code AND a textual secret key (for password managers without a scanner) plus recovery codes.
  • New users are created exclusively by the admin and receive a randomly generated password (Str::password(20, symbols: false) — deliberately without special characters, since Markdown mail templates would otherwise interpret */_ as formatting and strip them from the password) by email (App\Notifications\AccountCreated, ShouldQueue). On first login, must_change_password forces a password change before the rest of the app becomes usable.

Roles/Groups

Table groups (id, name, is_admin, timestamps). Every user belongs to exactly one group via users.group_id. There is no fine-grained role/permission system — only the group's is_admin flag decides admin rights. The groups "Administrators" and "Users" are pre-seeded (migration seed_default_groups); additional groups can be created in the admin backend.

Because of this feature, users additionally has: group_id, must_change_password, is_active, disabled_at (deactivation instead of deletion), as well as Fortify's two_factor_secret, two_factor_recovery_codes, two_factor_confirmed_at.

Admin Backend

Reachable at /admin (routes/admin.php), protected by the middleware chain auth, password.change (enforces a required password change), and admin (checks $user->group->is_admin, otherwise 403).

Route Purpose
GET /admin/users user list
GET /admin/users/create, POST /admin/users create a user (random password + mail)
GET /admin/users/{user}/edit, PUT /admin/users/{user} edit a user (additionally password.confirm)
PUT /admin/users/{user}/deactivate, PUT /admin/users/{user}/reactivate deactivate/reactivate instead of delete (additionally password.confirm)
GET /admin/groups, GET /admin/groups/create, POST /admin/groups group list/create (creation additionally password.confirm)
GET /admin/groups/{group}/edit, PUT /admin/groups/{group}, DELETE /admin/groups/{group} edit/delete group (additionally password.confirm)
GET /admin/printers, GET /admin/printers/create, POST /admin/printers printer list/create (creation additionally password.confirm)
GET /admin/printers/{printer}/edit, PUT /admin/printers/{printer}, DELETE /admin/printers/{printer} edit/delete printer (additionally password.confirm)
GET /admin/slicer-profiles, GET /admin/slicer-profiles/create, POST /admin/slicer-profiles profile list/create (creation additionally password.confirm)
GET /admin/slicer-profiles/{slicerProfile}/edit, PUT /admin/slicer-profiles/{slicerProfile}, DELETE /admin/slicer-profiles/{slicerProfile} edit/delete profile (additionally password.confirm)
GET /admin/settings, PUT /admin/settings edit singleton settings (print_queue_enabled, print_queue_public_access, additionally password.confirm)

For details on printer/profile management, see Printers and Slicer Profiles; for the print-queue setting, see Print Queue.

Sensitive admin actions additionally require a fresh password confirmation (Fortify's password.confirm middleware, timeout in config/fortify.php). The frontend proactively checks the confirmation status via GET /user/confirmed-password-status (composable resources/js/composables/useConfirmsPassword.js + resources/js/Components/ConfirmPasswordModal.vue) and automatically lets the actual action continue after confirmation, instead of silently losing it on a 423.

app/Support/LastAdminGuard.php prevents the last active admin from being deactivated or demoted, or the last admin group from losing its is_admin flag or being deleted while it still has active members.

The initial admin user isn't created automatically on every deploy, but once, manually:

docker compose exec app php artisan app:ensure-admin-user --email=<address>

The command is idempotent (firstOrCreate), assigns the user to the Administrators group, and sends the same AccountCreated mail as for normally created users.

Delete Permissions

Without exception, all deleting/trash-related endpoints of the app are restricted to admin users (middleware admin at the route level in routes/web.php; additionally the corresponding buttons are completely hidden in the frontend for non-admins, not just disabled):

  • DELETE /slicer/{slicedFile}, DELETE /slicer
  • DELETE /trash/{fileId}, POST /trash/empty
  • POST /files/bulk/delete
  • DELETE /files/{file}, DELETE /files/{file}/tags/{tag}
  • DELETE /tags/{tag}
  • DELETE /projects/{project}
  • DELETE /print-logs/{printLog}

POST /trash/{fileId}/restore (restore) is deliberately excluded from this — not a delete operation. Viewing, downloading, and all non-deleting edit actions remain fully usable for normal users.

Scan Pipeline

The source inventory is cataloged via files:scan.

cd deploy
docker compose exec app php artisan files:scan

Options:

php artisan files:scan --dry-run
php artisan files:scan --no-render

The scan:

  • reads all files from Storage::disk('source')
  • ignores dotfiles and .trash
  • detects extension, size, mtime, folder
  • computes a content hash for new/changed files
  • detects revisions and backup variants
  • detects source/license from folder metadata
  • marks vanished files via missing_since
  • queues render jobs for new/changed non-image files

There's a Redis lock files-scan-command against parallel scans. RunScan additionally uses WithoutOverlapping.

The watcher container watches /data/source via inotifywait and triggers files:scan after a 30-second debounce. The scheduler additionally runs an hourly fallback scan.

In addition, entrypoint.sh calls files:scan-if-new every time the app container starts: if the files table is still empty (a fresh self-hosting first run), this automatically queues a RunScan job without you having to trigger files:scan manually. On any already populated instance, the command does nothing.

Search and Duplicates

The gallery uses Meilisearch via Laravel Scout.

Indexed fields:

  • id
  • filename
  • filename_words
  • folder_path
  • extension
  • source_platform
  • tags

Files with missing_since or deleted_at are not indexed.

Duplicate types:

  • exact duplicates: same content_hash
  • near duplicates: same mesh_metadata.geometry_hash, but different file content

The geometry hash is built by Blender from sorted world-space mesh vertex coordinates rounded to 0.01 mm. That's significantly more robust than just bounding box plus triangle count.

Trash and File Operations

File operations live in:

app/Services/FileOperations.php

Deletion happens in two stages:

  1. trash() moves the file within the source mount to .trash/<original path> and sets deleted_at.
  2. forceDelete() permanently deletes it from .trash/ and removes the DB record.

Restore moves it back from .trash/ to its original path. If another file has since taken that spot, the app aborts and requires manual resolution.

Move/copy always build target paths from a known filename plus target folder. Free-form paths from user input are never used directly.

Printers and Slicer Profiles

Printers and slicer profiles are database-backed, manageable under /admin/printers and /admin/slicer-profiles, without a redeploy.

Printers (printers, App\Models\Printer)

  • key (stable, referenced by sliced_files.printer_key), label, bed_x/bed_y/bed_z
  • driver_type is currently always 'plugin' — every printer has a driver plugin (driver_plugin_id, FK to driver_plugins) that runs isolated in the drivers container, see Driver Plugins.
  • driver_config (JSON, encrypted:array cast): the fields required by the respective plugin via config_schema (e.g. upload_url/ws_port/gcode_dir or octoprint_base_url/ octoprint_api_key for the two migrated drivers). Encrypted with APP_KEY because real credentials sit in the DB here rather than just in .env. If APP_KEY is ever rotated without migrating this column along with it, driver_config becomes permanently undecryptable.
  • SoftDeletes. Deletion is blocked while SlicerProfile rows still point to the printer (PrinterController::destroy(), checks $printer->slicerProfiles()->exists() — the restrictOnDelete() FK on slicer_profiles.printer_id alone isn't enough, because a soft delete isn't a real DELETE, so the FK doesn't fire).

Slicer Profiles (slicer_profiles, App\Models\SlicerProfile)

The admin form distinguishes curated fields (ones that actually vary between profiles AND take effect during slicing: layer height, temperatures, perimeters/top-bottom layers, retraction, filament/nozzle, start/end G-code) from a generic extra_settings key-value list (JSON column) for everything else (speeds, accelerations, fan, machine_max_*, …) — this still covers any arbitrary PrusaSlicer option without needing a form field per setting.

fill_density/fill_pattern and support_material_style/support_material_pattern are deliberately not curated fields: SlicerController::store()/slicer-worker always override them anyway via CLI flag based on the job selection in the slicer UI, so a profile value for them would never take effect.

SlicerProfile::toIni() generates the complete PrusaSlicer INI text from this (curated fields as known keys, bed_shape/max_print_height from the printer relation, then all extra_settings pairs appended). start_gcode/end_gcode sit in the DB as real multi-line text (for the <textarea>); toIni() converts them, when generating, into PrusaSlicer's single-line notation with a literal \n (see "Slicer Worker" below).

Handoff to the Slicer Worker

The SliceFile job sends profile_ini (the full generated INI text) instead of a file reference in the request body to /slice. The worker itself writes it to a temp file inside the tempfile.TemporaryDirectory() it's using anyway and loads it via --load — no shared volume for profiles needed.

G-code History Stays Independent of Later Changes

sliced_files additionally freezes printer_label, printer_bed (JSON), and filament at slicing time (analogous to profile_name/plate_items). Reason: mapSlicedFile()/ show()/sendToPrinter() would otherwise read these values live from the now-editable printer/profile DB — a renamed or deleted printer would then corrupt old G-code history. With the snapshot, it stays correct regardless of whether the original printer or profile was later changed or deleted.

Bundled Default Profiles

Created once for the two bundled printers on the very first migrate --force (freely editable afterward under /admin/slicer-profiles):

Key Printer Layer Height Firmware Flavor Bed
ender5max-008 Ender 5 Max 0.08 mm Klipper 400 x 400
ender5max-012 Ender 5 Max 0.12 mm Klipper 400 x 400
ender5max-020 Ender 5 Max 0.20 mm Klipper 400 x 400
ender5s1-008 Ender 5 S1 0.08 mm Marlin 2 220 x 220
ender5s1-012 Ender 5 S1 0.12 mm Marlin 2 220 x 220
ender5s1-020 Ender 5 S1 0.20 mm Marlin 2 220 x 220

Shared basis:

  • FFF
  • 0.4 mm nozzle
  • 1.75 mm filament
  • PLA
  • machine_limits_usage = time_estimate_only

Ender 5 Max:

  • bed_shape = 0x0,400x0,400x400,0x400
  • gcode_flavor = klipper
  • Bed 45 °C
  • Start G-code with a purge line at X just below 0 and Y 120..245
  • End G-code presents at X395/Y395

Ender 5 S1 (not yet calibrated/verified on a real test print — starting values derived from the Max profiles):

  • bed_shape = 0x0,220x0,220x220,0x220
  • gcode_flavor = marlin2
  • Bed 55 °C
  • Start G-code with G29
  • End G-code presents at X215/Y215

The profile display in the UI comes directly from the SlicerProfile records (key, name, layer height, filament).

Printer Providers

The printer connection is encapsulated behind an interface:

app/Services/PrinterDrivers/PrinterDriver.php

Interface methods:

  • fromConfig(array $printer)
  • upload(string $localPath, string $filename)
  • start(string $filename)
  • status()
  • outcomeFor(string $filename)

The factory:

app/Services/PrinterDrivers/PrinterDriverFactory.php

reads Printer::driverPlugin and builds a PluginPrinterDriver instance from it (an HTTP client to the drivers container, see Driver Plugins).

Ender 5 Max and Ender 5 S1

Both run as driver plugins (driver_plugins.key: creality-websocket and octoprint respectively, manageable under /admin/driver-plugins).

Creality WebSocket (Ender 5 Max):

  • Upload via HTTP multipart to driver_config.upload_url, filename as the last URL segment
  • Print start via proprietary Creality/DWIN WebSocket: {"method":"set","params":{"opGcodeFile":"printprt:<gcode_dir>/<filename>"}}
  • Status via WebSocket request ReqPrinterPara; printFileName decides whether a job is active, state is only evaluated in the context of the active/last-known job
  • Python-side: websockets.sync.client (synchronous API, matching the synchronous handle() contract)

OctoPrint (Ender 5 S1):

  • Upload via the OctoPrint REST API to /api/files/local, auth via X-Api-Key
  • Start via POST /api/files/local/<filename> with {"command":"select","print":true}
  • Status via /api/printer + /api/job; HTTP 409 (reachable but no printer connected) is still handled specially

Driver Plugins

A way to add a new printer driver — without a code change/deploy, but with the same security level as fixed, compiled-in code, because the plugin code runs exclusively isolated in the drivers container (driver-runner/), never in the app container itself. This lets a self-hoster write a driver for a new protocol (Prusa Connect, Moonraker/Klipper, Bambu, …), export it, and have other 3D Vault instances import it via /admin/driver-plugins — without writing any PHP or triggering a deploy.

Data Model (driver_plugins, App\Models\DriverPlugin)

  • key, label, description, author
  • source_code (TEXT): full Python source code of the plugin. Deliberately unencrypted — it never contains credentials (those stay in Printer::driver_config, encrypted:array) and needs to remain exportable/importable between instances.
  • config_schema (JSON, a list of {key, label, type, secret}): describes which driver_config fields a printer with this plugin needs (e.g. base_url/api_key) — drives the dynamic form under /admin/printers (field type, password input when secret: true).
  • SoftDeletes, delete lock analogous to Printer/SlicerProfile: locked as long as a Printer with driver_type === 'plugin' still points to the plugin (DriverPluginController::destroy(); restrictOnDelete() doesn't fire on a soft delete).

The Plugin Contract

A plugin is a Python module with a top-level function:

def handle(op: str, config: dict, params: dict) -> dict:
    ...
  • opupload | start | status | outcome (identical to PrinterDriver's interface methods)
  • config = Printer::driver_config, decrypted
  • params depending on op:
    • upload: {"file_path": <absolute path to the G-code file in the driver-runner container>, "filename": <str>}
    • start: {"filename": <str>}
    • status: {}
    • outcome: {"filename": <str>}
  • The return mirrors PrinterStatus/existing driver behavior 1:1, e.g. status: {"online": bool, "state": str, "file_name": str|None, "progress_percent": int|None, "elapsed_seconds": int|None, "remaining_seconds": int|None, "current_layer": int|None, "total_layers": int|None, "error": str|None}

A curated, pinned selection of Python libraries is permanently pre-installed in the driver-runner image (instead of runtime pip install) and covers virtually every realistic printer protocol — a full list with intended use plus a detailed practical guide to writing your own plugin: Writing Your Own Driver Plugins. A complete reference example: driver-runner/examples/example_http_driver.py.

App\Services\PrinterDrivers\PluginPrinterDriver

Implements PrinterDriver like the two built-in drivers, but doesn't execute any third-party code itself — it's just a thin HTTP client to the drivers container (POST /run). Since this driver needs more than just driver_config (the plugin source code from the DriverPlugin relation), it doesn't fit the Class::fromConfig(array) convention of the built-in drivers — it deliberately throws a LogicException there and is instead built directly with the Printer model via PrinterDriverFactory::make() (PLUGIN_DRIVER_TYPE = 'plugin' branch, before the DRIVER_CLASSES lookup).

During upload(), the driver translates the app-container-absolute G-code path (Storage::disk('local')->path(...)) into the plain filename (basename($localPath), without a gcodes/ prefix) — the drivers container doesn't see the app path, only its own :ro mount of the same storage/app/private/gcodes folder, and GCODES_ROOT already points directly at this folder (not its parent) — with an extra gcodes/ prefix, driver-runner would look in the non-existent /data/gcodes/gcodes/.

Isolation Boundary of driver-runner

  • Its own container, with no access to the database, session, or APP_KEY — plugin code gets only config (the decrypted driver_config of the respective printer) and the G-code file path, never more.
  • No persistent plugin storage in this container: source_code comes fresh from the Laravel DB with every /run call and is executed in a throwaway tempfile.TemporaryDirectory() (on tmpfs), then immediately discarded.
  • Every call runs in a fresh python -I subprocess (isolated mode: no PYTHON* env variables, no user site directory) with resource limits (RLIMIT_CPU/RLIMIT_AS/RLIMIT_NPROC/ RLIMIT_FSIZE) and a timeout, plus Compose-level hardening: cap_drop: [ALL], security_opt: no-new-privileges, a read_only root filesystem (only /tmp as tmpfs), pids_limit, mem_limit, non-root user in the image (unlike the render/slicer workers, where this is still pending — deliberately not deferred to "medium-term" here, because this container demonstrably runs the least trustworthy code).
  • No kernel sandbox (no nsjail/gVisor) — disproportionately fragile to assume for a single-tenant self-hosting app on arbitrary Docker hosts. The actual protective effect comes from "its own container without DB/session/secret access", not from a hardened interpreter — a plugin can still open arbitrary network connections within its own container (technically necessary to talk to a printer at all) and max out its resource limits, but cannot reach the Laravel app, the database, or other containers.
  • Diagnostic text from a failed plugin call (stdout/stderr) is both truncated and scrubbed of all config values (_redact_config_values() in driver-runner/main.py) before being returned to Laravel — a careless plugin that, say, prints the API key in an error message shouldn't leak the secret all the way to the admin UI/Laravel logs.
  • /validate (an AST syntax check that never executes the code) is a pure convenience check for the admin UI ("check code" button in create/edit) — store()/update() don't depend on the drivers container being reachable.

Writing Your Own Driver Plugins

A practical guide for anyone who wants to write a new driver under /admin/driver-plugins (or understand/adapt an imported driver) — e.g. for Prusa Connect, Moonraker/Klipper, Bambu Lab (LAN or cloud), or any other protocol a printer/printer cloud speaks. The goal of this section: everything needed for that, in one place, precise enough to get started right away.

Workflow in the Admin Area

  1. /admin/driver-plugins → "New Plugin".
  2. Fill in key/label/description/author (pure metadata).
  3. Define the credential fields (config_schema): one line per field that a printer with this driver will need later (e.g. ip, access_code, serial_number). Type string/url/ number, check "secret" for password input fields (purely UI-side — driver_config is already fully encrypted regardless of the checkbox).
  4. Write the Python source code — see the contract below. "Check code" does a pure AST syntax check (no execution, see Isolation Boundary), useful as quick feedback, but doesn't replace a real test against a running printer.
  5. Save. Immediately selectable as a driver type under /admin/printers — no deploy, no container restart.
  6. Test: create a printer with the plugin, enter real credentials, fetch the live status on /slicer (uses status()) or send a G-code to the printer (uses upload()+start()).

The Contract in Detail

A plugin is a Python module with exactly one required top-level function:

def handle(op: str, config: dict, params: dict) -> dict:
    ...

driver-runner calls it once per action (never more, no state between calls — see Isolation Boundary). op is one of four fixed values, with exactly defined params in and a dict return out:

op When called params Expected return
upload G-code is transferred to the printer (SlicerController::sendToPrinter()) {"file_path": <absolute path to the G-code file>, "filename": <str>} {} on success
start Print is started, right after upload {"filename": <str>} {} on success
status Fetch live status — runs synchronously when loading/polling /slicer, must respond quickly {} See below
outcome Periodic reconciliation of running prints (prints:check, every 5 min.) {"filename": <str>} {"outcome": str | None}

config is the same for every op: Printer::driver_config, already decrypted, with exactly the keys defined in the plugin's config_schema (e.g. {"ip": "192.168.1.50", "access_code": "12345678"}).

Return value of status (if a field is missing, it's treated as null/default when mapped to PrinterStatus — not every field is available for every protocol, e.g. OctoPrint doesn't provide layer numbers by default):

{
    "online": bool,                    # False -> all other fields are ignored
    "state": str,                      # idle|printing|paused|complete|failed|aborted|error|unknown
    "file_name": str | None,
    "progress_percent": int | None,    # 0-100
    "elapsed_seconds": int | None,
    "remaining_seconds": int | None,
    "current_layer": int | None,
    "total_layers": int | None,
    "error": str | None,               # only meaningful when online=False, shown in the admin UI
}

Return value of outcome: {"outcome": "printing" | "paused" | "complete" | "failed" | "aborted" | None}None means "no reliable statement possible" (printer unreachable, or another job is now active in the meantime). A caller that gets None leaves the previous status unchanged instead of guessing it — never just return "aborted" etc. simply because it's unclear what's currently happening.

Error cases: for upload/start, simply raise an exception (raise RuntimeError(...), response.raise_for_status(), …) — driver-runner catches it, truncates and scrubs the diagnostic (see below), and reports it back as a failure. For status/outcome, never raise, but return {"online": False, "state": "offline", "error": "..."} or {"outcome": None} respectively — status() runs synchronously during page load; an exception there would crash the entire /slicer page.

Available Python Libraries

Permanently pre-installed in the driver-runner image (driver-runner/requirements.txt), curated and pinned — no pip install possible or intended at runtime (see Isolation Boundary; this is a deliberate security boundary, not an oversight). Also the complete Python standard library (among others socket, ssl, ftplib, struct, hashlib/hmac, base64, xml.etree.ElementTree, subprocess — the latter is barely worth it though, see the limits below).

Library What for
requests HTTP/REST (most printer and cloud APIs)
httpx Modern HTTP/1.1+2 alternative to requests, if preferred
websockets WebSocket protocols (e.g. the Creality/DWIN driver uses websockets.sync.client)
paho-mqtt MQTT — local (Klipper/Moonraker extensions) or cloud-hosted (e.g. Bambu Lab), incl. TLS
pyserial Serial/USB connection (many Marlin printers directly via USB) — see the device-access note below
paramiko SSH/SFTP (e.g. a Moonraker host managed via SSH instead of REST)
protobuf Binary protocols that use Protocol Buffers instead of JSON
msgpack Compact binary serialization, as used by some firmwares instead of JSON for status reports
pyjwt JWT tokens, as issued by many manufacturer cloud APIs for authentication
cryptography Cryptography beyond hashlib/hmac (request signatures, certificates, custom encryption) — TLS itself is already handled by requests/websockets/paho-mqtt/ftplib
zeroconf mDNS/Bonjour — e.g. addressing a printer with a changing LAN IP via its hostname instead of a fixed IP
bleak Bluetooth Low Energy (some consumer/resin printers) — async-only, see the note below

bleak is asynchronous: handle() itself is a synchronous function. A BLE operation inside it can still be integrated by wrapping it in asyncio.run(...):

import asyncio
from bleak import BleakClient

async def _read_status(address):
    async with BleakClient(address) as client:
        return await client.read_gatt_char("...")

def handle(op, config, params):
    if op == "status":
        raw = asyncio.run(_read_status(config["ble_address"]))
        ...

Limits no library can fix:

  • Serial/USB (pyserial) and Bluetooth (bleak) need extra device access that the drivers container doesn't have by default (no /dev/ttyUSB0 mount, no host Bluetooth access) — merely having the pip package present isn't enough. Anyone who needs this adds e.g. devices: ["/dev/ttyUSB0"] for the drivers service in their own deploy/docker-compose.override.yml. Deliberately not on by default, because device passthrough increases the container's attack surface and most instances never need it.
  • mDNS (zeroconf) over Docker bridge networks is unreliable — multicast traffic (mDNS runs over 224.0.0.251:5353) isn't always forwarded cleanly by Docker's default bridge driver. For reliable discovery, consider network_mode: host for the drivers service in an override file of your own (with the same trade-offs as device passthrough).
  • subprocess/external programs inside a plugin: technically possible (part of the Python standard library), but read_only: true (see Isolation Boundary of driver-runner) leaves little room for anything that wants to write files itself, and the resource limits apply to the entire process tree. For anything one of the libraries above covers, they're always the better choice.
  • No installing at runtime. If a library for a new protocol is really missing, that's a driver-runner/requirements.txt change (code change, image rebuild) — no plugin can get around this on its own, that's by design.

Runtime Limits

  • Timeouts (env-configurable, defaults): status/outcome 15s, start 30s, upload 300s — a plugin that takes longer gets aborted ({"success": false, "error": "Plugin timeout (<op>)"}). Especially relevant for status: this runs synchronously while loading /slicer.
  • Resource limits per call: CPU time, memory, process/thread count, maximum file size — all configurable via env variables of the drivers container (PLUGIN_CPU_SECONDS, PLUGIN_MEMORY_MB, PLUGIN_NPROC, PLUGIN_FSIZE_MB), see deploy/docker-compose.yml.
  • No state between calls (every handle() call starts in a fresh interpreter, see Isolation Boundary) — connection setup (WebSocket handshake, MQTT connect, HTTP login) has to happen fresh on every call, no caching an open connection/session token between two status() polls. Not an issue for most protocols (short connect-request-response-disconnect cycle, see the creality_websocket plugin as a template) — potentially relevant for response time with cloud APIs that have an expensive login flow, not for correctness.

Best Practices

  • Never build config values into exceptions/prints if it can be avoideddriver- runner does scrub known config values from error texts before they're returned (see Isolation Boundary), but that's a safety net, not an invitation. An error message like f"HTTP {response.status_code}" is just as helpful as one with the API key in it.
  • Set short timeouts on every individual network call (requests.get(..., timeout=5) etc.) — the outer driver-runner timeouts only kick in as a last resort; a hanging socket without its own timeout blocks needlessly long until then.
  • status()/outcome() never raise (see contract above) — fall back cleanly to offline/None on every error.
  • Follow the return shape exactly (keys, types) — PluginPrinterDriver maps the response directly onto PrinterStatus; missing/mis-named keys silently become null, no error message.
  • config_schema keys are also Python dict keys — choose sensible, stable names (access_code instead of field1), they show up 1:1 in the form AND in export/import.
  • When passing files={"file": (...)} to requests, always explicitly include a third tuple entry (content type)files={"file": (filename, fh, "application/octet-stream")}, not just (filename, fh). Without the third entry, requests sends no Content-Type header at all for this part of the multipart body (unlike, say, curl -F, which sets one automatically). Most servers (including OctoPrint) tolerate this without complaint, but some minimal/embedded printer HTTP servers don't — their multipart parser then resets the connection mid-request instead of sending a response (ConnectionResetError/"Connection reset by peer"). Reproduced and fixed live against real hardware (2026-09-19, creality-websocket plugin) — the effect only surfaced on the very first real-world test against actual hardware, because the migration from the previous PHP driver had previously only been verified against simulated endpoints.

Debugging and Testing

  • "Check code" in the admin form catches syntax errors and a missing handle() before anything is even saved.
  • A failure during real use (printer status/upload) shows the (truncated, scrubbed) diagnostic text directly in the admin UI — usually already enough to spot typos/wrong field names.
  • For deeper debugging directly against the running container, without going through the app:
    docker compose exec app curl -s http://drivers:8000/run \
      -H 'Content-Type: application/json' \
      -d '{"op":"status","source_code":"def handle(op,config,params):\n    return {\"online\": True, \"state\": \"idle\"}\n","config":{},"params":{}}'
    
    (In the embedded Python code, Python's True/None count, not JSON's true/null — the source_code value itself is a string, not nested JSON.) (Source code directly in the request, no detour via the DB — a fast iteration loop.)
  • docker compose logs drivers only shows Uvicorn access logs, no plugin-internal diagnostics (those only come back via the /run response) — for debugging, the admin UI or the curl call above is usually enough.

Second Example: MQTT Skeleton

Shows the basic structure for an MQTT-based protocol (e.g. Klipper/Moonraker extensions or as a starting point for Bambu Lab LAN mode) — not a complete driver, just the structure:

import json
import threading

import paho.mqtt.client as mqtt

def _fetch_status(config, timeout=5.0):
    result = {}
    done = threading.Event()

    def on_message(client, userdata, msg):
        result.update(json.loads(msg.payload))
        done.set()

    client = mqtt.Client()
    client.on_message = on_message

    try:
        # status() must NEVER raise per the contract (see "The Contract in
        # Detail" above) - a DNS/connection error here is the normal case
        # for a printer that's turned off/unreachable, not a bug.
        client.connect(config["ip"], int(config.get("port", 8883)))
    except OSError:
        return None

    client.subscribe("printer/report")
    client.loop_start()
    done.wait(timeout=timeout)
    client.loop_stop()
    client.disconnect()

    return result or None


def handle(op, config, params):
    if op == "status":
        data = _fetch_status(config)
        if data is None:
            return {"online": False, "state": "offline", "error": "No response from the printer."}
        return {"online": True, "state": data.get("state", "unknown"), "file_name": None,
                "progress_percent": None, "elapsed_seconds": None, "remaining_seconds": None,
                "current_layer": None, "total_layers": None, "error": None}

    raise ValueError(f"Unknown operation: {op}")

Complete, runnable reference (HTTP-based): driver-runner/examples/example_http_driver.py. Two real, complete examples (WebSocket and REST) sit as driver plugins directly in the database: creality-websocket and octoprint — viewable and exportable via the admin UI.

Export and Import

Slicer profiles and driver plugins can each be exported individually and re-imported via an uploaded file — handy for sharing a configuration with another 3D Vault instance, or transferring between a test instance and a production instance. Both use the same file format (App\Services\VaultExport): a JSON envelope with a fixed marker (3dvault_export: true), type (slicer_profile | driver_plugin), a schema version, and a SHA-256 checksum over the payload.

The filename is never the identifying feature — it can be renamed freely; the importer only checks the envelope content and rejects anything else (missing marker, wrong type, wrong checksum). The checksum is deliberately a plain SHA-256, not a signature encrypted with APP_KEY — exports are meant to remain exchangeable between different 3D Vault instances, that's the whole point. This is format detection against mistakes/broken files, not tamper protection against an admin who deliberately edits the file by hand — the admin area is only reachable by admins anyway.

An import never creates anything automatically in either case: the file only pre-fills the respective "create new" form; only an explicit click on "Save" applies the values and, e.g., actually makes an imported driver plugin runnable via PrinterDriverFactory.

Exporting and Importing Slicer Profiles

Under /admin/slicer-profiles: a download link per row for export, an upload form for import. An export references its printer via the stable Printer::key, not the local ID — if the import doesn't find a local printer with this key on the target instance, the printer field in the pre-filled form stays empty and the admin selects manually.

Exporting and Importing Driver Plugins

Under /admin/driver-plugins, mechanically identical to slicer profile export/import. An export never contains credentials — those live exclusively in Printer::driver_config (encrypted:array), never in the plugin itself. A driver plugin can therefore be shared safely, even if it was written for a specific printer.

Slicer UI

The slicer page lives in:

resources/js/Pages/Slicer/Index.vue
resources/js/Components/PlateViewer.vue
resources/js/Components/PlatePreview.vue
resources/js/Components/PlatePreviewSvg.vue

The page shows:

  • sliceable files (inventory)
  • virtual print bed
  • printer selection
  • profile selection
  • infill and support options
  • list of generated G-code versions
  • live printer status
  • G-code download, print start, and delete functions

All formats from config('slicer.supported_extensions') are selectable (stl, obj, ply, blend, blend1, scad, see App\Models\File::scopeSliceable()):

  • native .stl files always, regardless of render status — the slicer worker parses them directly.
  • every other format only after the user has specifically converted that one file via the "Convert to STL" button on its detail page (stl_status=done, see Targeted STL Conversion). This never happens automatically during a scan or routine re-render — otherwise every .blend/.obj/.ply/.scad file in the entire inventory would get converted along, which is explicitly not wanted.

The slicer worker itself stays unchanged and simple: in both cases it just gets handed a finished .stl — for .stl originals directly from SOURCE_ROOT, for every other format the derived stl_path from RENDERS_ROOT (see Slicer Worker). Exception: the public Print Queue for anonymous hackspace uploads deliberately stays .stl-only — files there come unauthenticated from strangers, and .blend (potentially auto-executing embedded Python scripts) and .scad (full OpenSCAD/CGAL pipeline, DoS potential) remain too risky for this upload path. The format extension only applies to files already cataloged in the internal slicer flow.

Edit Fields per Selected Part

  • Position (X/Y): no input fields — positioning happens exclusively via dragging on the 3D print bed.
  • Rotation X/Y/Z: free numeric fields (degrees).
  • Size X/Y/Z (mm): shows/sets the actual edge length in mm along the respective bed axis (not the part's native STL axis — after a 90° rotation, "Size Z" shows the actual vertical edge, not the originally native Z axis). itemBedSizeMm()/ setItemBedSizeMm() in Index.vue rotate the native half-extents for this (from file.mesh_metadata.bbox × scale_x/y/z) with the same rotation matrix as itemFootprintPolygon() into bed space and sum, per bed axis, the native extents weighted by |matrix entry| (the standard formula for the AABB of a rotated box). At rotations in 90° steps, this reduces exactly to a 1:1 renaming of the axis. When editing, the native axis with the largest weight is adjusted for the edited bed axis, the contribution of the other two native axes stays fixed — at rotation angles off 90° multiples, several native axes can contribute similarly strongly, in which case only the most dominant one is still adjusted. Internally still stored as a scale factor (scale_x/scale_y/scale_z, see "Data Model"), only the UI shows/accepts mm.

Print Bed and Placement

The active print bed comes from the selected printer profile. In the frontend, the user does select a printer, but the profile is authoritative on submit. Each profile is mapped to a printer via the filename.

Configured printers:

Key Label Bed
ender5max Ender 5 Max 400 x 400 x 400 mm
ender5s1 Ender 5 S1 220 x 220 x 280 mm

Coordinate System in the Frontend

The user coordinates are classic bed coordinates:

  • x: 0 to bed width
  • y: 0 to bed depth
  • Origin: front/left in the SVG/topdown context

In PlateViewer.vue, this becomes a Three.js world position:

group.position.x = item.x - props.bed.x / 2;
group.position.z = props.bed.y / 2 - item.y;

So the 3D scene is centered around the bed's middle.

Auto-Arrange

Index.vue uses a simple shelf bin-packing approach:

  • largest footprints first
  • row layout
  • target width from the square root of the total area
  • the block is centered on the bed's middle
  • BED_MARGIN = 6
  • positions are clamped to the bed boundaries

The footprint (itemFootprint()) is the axis-aligned box around itemFootprintPolygon() (see below).

Collision Warning

Collision checking uses real mesh-vs-mesh intersection tests (three-mesh-bvh), not a bounding- box/hull approximation — this correctly detects collisions after tilting (rotation_x/rotation_y) too, and doesn't falsely flag interlockable, concave parts (e.g. two "C"-shaped parts) as colliding.

For each part, when loaded, a merged, position-only geometry is built from all meshes under modelRoot (GLB or placeholder box) in modelRoot's local coordinate system (buildCollisionGeometry()) and given a bounds tree once (geometry.computeBoundsTree()). On every repositioning/rotation (recomputeCollisions(), called at the end of syncItems() as well as after every load), the exact transformation matrix between the two modelRoot frames is computed for every pair of parts via boundsTree.intersectsGeometry(otherGeometry, matrix), and a real geometric intersection test is performed. PlateViewer.vue colors the affected parts itself and reports the colliding IDs to Index.vue via a colliding event (purely for displaying the warning line, not fed back as a prop — otherwise there's a risk of a reactive loop). itemFootprintPolygon()/itemFootprint() remain in Index.vue, but are now only used by autoArrange() for the (still axis-aligned) shelf packing.

The warning doesn't prevent submission. It only shows that parts overlap in the frontend.

Print Queue

A print queue per printer under /druckwarteschlange. Anyone submitting an STL file provides a note (textarea, e.g. material/color) as well as a freely entered quality and infill wish — both are purely recommendations to the operator, they never automatically flow into an actual slicing job.

Activation and Public Access (Both Configurable)

Two independent switches under /admin/settings (Setting singleton row, App\Models\Setting):

  • print_queue_enabled (default true): turns the queue off completely when false — every route then returns 404, regardless of login. Existing print_requests rows remain in the DB and are immediately back once reactivated.
  • print_queue_public_access (default false): only takes effect if the queue is enabled. By default, reachable only for logged-in users like the rest of the app; when enabled, it also lets guests without an account submit and see the board (hackspace use case, shared printers).

Both checks run in App\Http\Middleware\EnsureQueueAccessAllowed (alias queue.access, replaces auth on all routes in routes/queue.php, in this order: first print_queue_enabled, then print_queue_public_access). Changing status/deleting always stays tied to a real login, independent of this.

routes/queue.php, like routes/admin.php, is registered via bootstrap/app.php's then: callback, not part of the large auth group in routes/web.php.

print_queue_enabled is additionally shared globally with every Inertia page (HandleInertiaRequests::share(), prop printQueueEnabled) — this lets AppLayout.vue hide the "Print Queue" nav link without every controller that uses AppLayout having to pass this value separately.

Upload Security Measures

The print queue is the only place in the app that accepts file uploads (otherwise only read access to the existing data inventory). PrintQueueController::store() is built correspondingly carefully:

  • .stl only — regardless of login mode. No .blend (can contain embedded, potentially automatically running Python scripts) and no .scad (full OpenSCAD/CGAL pipeline) for files that, in public mode, can come from unknown people.
  • Content sanity check before writing (not a full parser): ASCII STL (solid prefix + facet occurrence) or binary STL (triangle count from byte offset 80 checked against file size, the identical formula to slicer-worker/main.py's load_stl()).
  • Its own size limit (50 MB) below the global php.ini limit (upload_max_filesize=64M).
  • The target path is generated server-side (print-queue/{printer-key}/{uuid}/{sanitizedName}.stl) — the original filename never ends up raw in the path, the same pattern as FileOperations::buildTargetPath().
  • Rate limit per IP at the app level (RateLimiter::for('print-queue-upload', ...), AppServiceProvider::boot()) and additionally at the Traefik level (3dvault-queuelimit, deploy/docker-compose.yml, analogous to the existing 3dvault-loginlimit for /login) — an abuse attempt is thereby already caught before PHP-FPM/Laravel.
  • A honeypot field (website) in the form — invisible to humans, a bot fills it in and gets rejected without comment.

Catalog Integration

Submitted files become a completely normal File record, just like a NAS scan find (a reduced variant of ScanFiles::runScan(): content_hash, extension, size, render_status='pending', then RenderFile::dispatch()) — they therefore show up in the gallery, duplicate detection, search like every other cataloged file, just under their own print-queue/ prefix.

From Request to Actual Print

A logged-in user (no admin requirement) can "Open in Slicer" an open request — deep link /slicer?file_id=…&fulfills_request=…, extending the existing placeFile pattern in SlicerController::index()/Slicer/Index.vue with a second, accompanying parameter. The request's infill wish is parsed server-side from the free text (the first number 0100) and pre-filled as suggestedInfillDensity — the quality wish (layer height), on the other hand, deliberately isn't, because there's no guarantee that a matching profile even exists (unlike the infill percentage, which is a pure form field regardless of the chosen profile). As soon as this becomes a real slicing job (SlicerController::store()), the app automatically sets sliced_file_id and status='in_druck' on the PrintRequest. If the associated SlicedFile is later deleted (destroy()/destroyAll()), the request jumps back to status='offen', instead of staying stuck on "being printed" without a job.

Status values: offen (open) → in_druck (printing, automatic) → erledigt/storniert (done/canceled, manual, any logged-in user; deleting the request itself remains admin-only, consistent with the app's other delete conventions). Deleting only removes the PrintRequest row, not the underlying catalog file — that can be managed like any other file via normal file deletion.

Print Status and Print Logs

The slicing status only means:

  • queued: job waiting
  • slicing: worker running
  • done: G-code was generated
  • failed: slicing failed

It doesn't say whether it has already been printed.

The actual print state in the UI is derived from the most recent PrintLog per SlicedFile:

PrintLog Status UI State
no log prepared
druckt (printing) printing
gedruckt (printed) printed
fehlgeschlagen (failed) print_failed
verschenkt (given away) given_away

The scheduler calls every 5 minutes:

php artisan prints:check

The command queries the appropriate printer for active logs and updates the status if the outcome can be determined reliably.

Operations

Checking Status

cd deploy
docker compose ps
docker compose logs --tail=100 app queue render slicer

Rebuilding

cd deploy
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --build

(Without Traefik, use -f docker-compose.yml -f docker-compose.standalone.yml accordingly.)

Restarting the App Container

docker compose restart app queue scheduler watcher

In production use, check beforehand whether a slicing or render job is currently running:

docker compose logs --tail=50 queue

Useful Artisan Commands

docker compose exec app php artisan files:scan
docker compose exec app php artisan files:scan --dry-run
docker compose exec app php artisan files:scan --no-render
docker compose exec app php artisan files:render
docker compose exec app php artisan files:render --all
docker compose exec app php artisan files:render --limit=50
docker compose exec app php artisan prints:check
docker compose exec app php artisan queue:failed
docker compose exec app php artisan route:list
docker compose exec app php artisan about
docker compose exec app php artisan app:ensure-admin-user --email=<address>

Queue worker:

docker compose logs -f queue

Worker health:

docker compose exec slicer python3 -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read())"
docker compose exec render python3 -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read())"

Changing a Slicer Profile / Creating a New Profile

Under /admin/slicer-profiles (admin login required): edit a profile or "New Profile" — curated fields plus the generic extra_settings line list for everything else. Takes effect immediately for the next slicing job, no container restart needed (see Printers and Slicer Profiles).

Adding a New Printer

Every printer has exactly one driver plugin (see Driver Plugins).

  1. If a matching plugin already exists (e.g. creality-websocket/octoprint, or a self-written/imported one): under /admin/printers → "New Printer" → select the plugin, fill in the fields it defines — no deploy needed.
  2. If no matching plugin exists yet: write or import a new driver plugin under /admin/driver-plugins (also no deploy needed).
  3. Create matching slicer profiles for the new printer under /admin/slicer-profiles.

Security

Positive points:

  • Complete login layer (Fortify, Argon2id, optional 2FA, see Authentication and Admin Backend) — the app is publicly reachable, but not usable without authentication.
  • All deleting actions are restricted to admin users (middleware + hidden UI, see Delete Permissions).
  • Double rate limiting against brute force: application-side (Fortify, email+IP and IP-only) and additionally at the Traefik level (3dvault-loginlimit).
  • Security headers (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) set additively via Traefik.
  • Sensitive admin actions require a fresh password confirmation (password.confirm).
  • Render, slicer, and driver-runner workers only sit on the internal Docker network.
  • Driver plugin code (admin-only) runs exclusively isolated in the drivers container with no DB/session/APP_KEY access, with resource/time limits and additional Compose hardening (cap_drop, no-new-privileges, read_only root FS, pids_limit, mem_limit, non-root) — see Driver Plugins.
  • The source mount is read-only for workers.
  • Only the app container has write access to the source mount.
  • Composer/npm/Artisan and the long-running processes queue:work/schedule:work/the watcher run as www-data (UID/GID adaptable to the host via PUID/PGID), not as root.
  • Apache denies access to anything above public/.
  • Laravel enforces HTTPS URL generation in production.
  • trustProxies() (bootstrap/app.php) is tied to SESSION_SECURE_COOKIE (the same signal as URL::forceScheme()), not unconditionally at: '*' — in standalone operation (docker-compose.standalone.yml, app container directly exposed via host port), any direct client could otherwise freely fake X-Forwarded-For and thereby make the IP-based rate limiters (Fortify login brute-force protection, print-queue upload limiter) ineffective. The app only trusts forwarded headers when SESSION_SECURE_COOKIE=true is set (Traefik operation, the only path reachable purely through the proxy) — in standalone operation, $request->ip() returns the real TCP peer address.
  • Paths in the render and slicer workers are validated against SOURCE_ROOT.
  • Printer::driver_config (among other things the OctoPrint API key) is stored encrypted in the DB with encrypted:array — a DB dump/backup therefore doesn't contain the credential in plain text.
  • .env and deploy/.env are not tracked in Git.
  • Storage::disk('local') has serve => false — the app doesn't actively use the generated storage/{path} routes anywhere, so they're completely disabled rather than merely secured.
  • Print-queue uploads (see Print Queue): .stl only with a content sanity check, server-generated storage path, its own size limit, app and Traefik rate limit, honeypot field — changing status/deleting always stays tied to a real login, independent of the public toggle.

Deliberate security trade-offs (by explicit request, not accidentally left open):

  • SESSION_LIFETIME=43200 (30 days), remember-me active by default. A stolen session or remember cookie stays valid for that long — 2FA only protects at login itself, not afterward.
  • There's an unused Traefik middleware definition (3dvault-lanonly) for an IP allowlist that is currently not referenced by any router — ready to use if needed.
  • The password rule doesn't require special characters (min. 9 characters, upper/lowercase, numbers, leak check).
  • Setting::print_queue_public_access can deliberately make the print queue (submitting + board) fully reachable without authentication — the default is false (login-only), the activation is an explicit per-instance admin decision, not an oversight.

Open items / hardening potential:

  • The render and slicer containers parse foreign/complex 3D files. In the medium term, they should get cap_drop, security_opt: no-new-privileges, pids_limit, CPU/memory limits, and possibly a read-only root FS. This also applies to the publicly reachable app container.
  • Deployment is bind-mount-based and not immutable.
  • Migrations run automatically at app start.
  • Server-side validation of the print bed currently only checks the center-point coordinate, not the full transformed footprint.
  • There's no fine-grained role/permission system, only a binary is_admin flag per group — deliberately sufficient for the current use case (admin/user), but not a basis for more differentiated access rights without a rework.
  • Printer::driver_config is encrypted with APP_KEY, but there's no documented key-rotation process in this repo. If APP_KEY is rotated without migrating this column along with it, driver_config becomes permanently undecryptable — before any rotation, make sure to work out how encrypted columns migrate along with it.
  • The content check for print-queue uploads (PrintQueueController::assertLooksLikeStl()) is deliberately a best-effort sanity check, not a full parser and not a virus scan — it filters out obvious nonsense, but is no hard guarantee against every kind of manipulated file.

Known Functional Limits

Print Bed Boundaries

SlicerController::store() validates:

  • x between 0 and bed.x
  • y between 0 and bed.y

The slicer worker likewise only validates the center position.

It's currently not checked whether the XY footprint, transformed by rotation, scaling, and Z-lift, lies entirely within the print bed. A large part can therefore have its center within the bed but still extend past the edge.

Recommended fix:

  • after transform_stl() in the slicer worker, compute the real XY bounding box of the transformed triangles
  • check against 0 <= x <= bed.x and 0 <= y <= bed.y
  • abort with 422 on a violation

Collisions

The frontend collision check (in PlateViewer.vue, see "Collision Warning" above for the development stages and architecture):

  • tests each part's real mesh geometry against the others (three-mesh-bvh), not just a bounding box, hull, or rectangle — concave cutouts (e.g. two interlockable "C" parts) are therefore correctly not flagged as a collision, as long as the triangles don't actually touch
  • accounts for rotation_x, rotation_y, AND rotation_z as well as scale implicitly, because the transformed real geometry is tested directly
  • does not prevent submission

This is useful as a UI hint, but not as a binding slicing safeguard.

Supported Slicer File Formats

The plater can do more than just .stl — every format from config('slicer.supported_extensions') becomes sliceable once the user has specifically converted it to STL (see Targeted STL Conversion). The slicer worker itself still only parses .stl:

SUPPORTED_EXTENSIONS = {".stl"}

in slicer-worker/main.py — Laravel always delivers it a finished STL, whether native or derived by the render worker, see Slicer Worker.

Real limits that remain:

  • .3mf and .step/.stp can neither be rendered nor converted (see Supported File Formats further up).
  • Conversion is a manual step per file, not an automatic bulk import — deliberately decided this way so that the entire inventory doesn't run through the Blender export on every scan.
  • The public print queue still exclusively accepts .stl uploads (a security boundary for anonymous submissions, see Print Queue).

Rotation

The rotation mapping between GLB/Three.js and STL/PrusaSlicer is deliberately complex and extensively commented in the code. Only make changes to it with real test models and by comparing the preview against the transformed STL/G-code.

Dashboard Query Does Not Work Under SQLite

Under SQLite (the test suite always runs against SQLite via phpunit.xml, see Tests), DashboardController::index() returns a 500 — the "top projects" query uses having('files_count', '>', 0) on a column alias, which SQLite's stricter HAVING rules reject (HAVING clause on a non-aggregate query). Not affected under MariaDB (production operation). Currently only affects the test suite — no test calls GET //GET /gallery.

Architecture

The following sections describe 3D Vault's technical structure in detail — helpful if you want to contribute, debug your own driver plugin beyond this guide, or understand the application more deeply. For pure self-hosting operation, you don't need anything past this point.

The application is a Laravel 13 project with Inertia/Vue/Three.js on the frontend and several isolated worker containers for rendering, slicing, and printer drivers:

Browser
  |
  | HTTPS
  v
Traefik proxy network
  |
  v
3dvault-app (Laravel, Apache, PHP)
  |\
  | \-- MariaDB (db)
  | \-- Redis (queue/cache/session)
  | \-- Meilisearch
  | \-- render-worker (FastAPI + Blender + OpenSCAD)
  | \-- slicer-worker (FastAPI + PrusaSlicer)
  | \-- driver-runner (FastAPI, isolated - executes driver plugin code)
  |
  +-- /data/source  -> ${VAULT_SOURCE_PATH}
  +-- storage/app/public/renders
  +-- storage/app/private/gcodes

Docker Services

Service Container Task
app 3dvault-app web app, Laravel, Apache, migrations, caches, frontend build (Node/npm/Vite)
queue deploy-queue-1 Laravel queue worker for scan, render, and slice jobs
scheduler 3dvault-scheduler php artisan schedule:work
watcher 3dvault-watcher inotify watcher on /data/source, triggers files:scan
db 3dvault-db MariaDB 11.4
redis 3dvault-redis Redis 7 for queue, cache, sessions
meilisearch 3dvault-meilisearch Meilisearch v1.11
render 3dvault-render FastAPI worker for Blender/OpenSCAD
slicer 3dvault-slicer FastAPI worker for PrusaSlicer
drivers 3dvault-drivers FastAPI worker, executes driver plugin code in isolation (see Driver Plugins)

Deployment

The stack is controlled from the deploy directory, via three compose files (see Self-Hosting above for the full flow): docker-compose.yml (base, always included) plus exactly one of the two overlay files docker-compose.standalone.yml (host port, no Traefik) or docker-compose.traefik.yml (Traefik labels + external proxy network):

cd deploy
docker compose -f docker-compose.yml -f docker-compose.traefik.yml ps
docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.traefik.yml logs -f app queue render slicer

Important: the code is mounted into the containers via bind mount:

volumes:
  - ..:/var/www/html

This makes the deployment deliberately flexible, but not immutable. Changes to the project directory take effect directly in the containers. When the app container starts, deploy/docker/entrypoint.sh handles:

  • composer install --no-dev --optimize-autoloader, if needed
  • npm ci && npm run build, if public/build/manifest.json is missing or package-lock.json is newer — a required step, without which every page fails with "Vite manifest not found". public/build/ is deliberately not in the repo (generated output, .gitignore) and gets covered by the bind mount, so a public/build baked into the image wouldn't help anyway — hence Node/npm in deploy/Dockerfile and the build here in the entrypoint instead of as a Docker build stage.
  • creating storage/cache directories
  • setting permissions for storage, bootstrap/cache, /var/log/3dvault
  • php artisan storage:link
  • php artisan migrate --force
  • php artisan config:cache
  • php artisan route:cache
  • php artisan view:cache

Queue, scheduler, and watcher use the same image but don't run the heavy setup steps. They wait on the healthcheck of the app container.

The first start therefore takes noticeably longer than later restarts (Composer and npm install/build both run once, including vite build) — that's normal, not a hang.

Data Model

files

Central table for all cataloged files in the source mount.

Important fields:

  • path: relative path within /data/source
  • filename
  • folder_path
  • extension
  • size
  • source_mtime
  • content_hash: SHA-256 over the file content
  • is_backup_variant
  • revision_group
  • revision_number
  • source_platform
  • source_item_id
  • source_license
  • thumbnail_path
  • topdown_thumbnail_path
  • glb_path
  • stl_status: pending|converting|done|failed — status of the targeted STL conversion triggered by the user per file (see Targeted STL Conversion), independent of render_status
  • stl_error: error text when stl_status=failed
  • stl_path: only set after successful targeted conversion — makes the file selectable in the slicer/plater (File::scopeSliceable())
  • mesh_metadata
  • render_status
  • render_error
  • project_id
  • last_seen_at
  • missing_since
  • deleted_at

After a successful render, mesh_metadata typically contains:

{
  "bbox": {
    "x_mm": 100.0,
    "y_mm": 80.0,
    "z_mm": 25.0
  },
  "triangle_count": 12345,
  "geometry_hash": "sha256..."
}

sliced_files

Stores a generated G-code version.

Important fields:

  • file_id: primary file of the bed
  • profile_key
  • profile_name
  • filament: frozen from SlicerProfile::filament_type at slicing time
  • printer_key
  • printer_label, printer_bed (JSON {x,y,z}): frozen from Printer at slicing time (see Printers and Slicer Profiles — reason: printers/profiles are editable/deletable; without a snapshot this would corrupt old history)
  • infill_density
  • infill_pattern
  • support_mode
  • support_style
  • plate_items
  • status: queued, slicing, done, failed
  • gcode_path
  • gcode_filename
  • gcode_thumbnail_path
  • gcode_size
  • gcode_hash
  • estimated_print_time
  • filament_used_mm
  • filament_used_g
  • error
  • sliced_at
  • uploaded_at
  • started_at

plate_items freezes the print bed at slicing time:

[
  {
    "file_id": 123,
    "path": "folder/model.stl",
    "filename": "model.stl",
    "x": 200.0,
    "y": 200.0,
    "rotation_x": 0,
    "rotation_y": 0,
    "rotation_z": 90,
    "scale_x": 1.0,
    "scale_y": 1.0,
    "scale_z": 1.0
  }
]

groups

Fields: id, name, is_admin, timestamps. users.group_id points to it. Pre-seeded: "Administrators" (is_admin=true), "Users" (is_admin=false). Details in Authentication and Admin Backend.

printers

Fields: id, key (unique), label, bed_x/bed_y/bed_z, driver_type, driver_plugin_id (FK, restrictOnDelete(), nullable, only set when driver_type === 'plugin'), driver_config (JSON, encrypted:array cast), timestamps, deleted_at. Details in Printers and Slicer Profiles.

driver_plugins

Fields: id, key (unique), label, description, author, source_code (TEXT, unencrypted), config_schema (JSON), timestamps, deleted_at. Details in Driver Plugins.

slicer_profiles

Fields: id, key (unique), printer_id (FK, restrictOnDelete()), name, curated profile settings (layer_height, first_layer_height, nozzle_diameter, filament_diameter, filament_type, gcode_flavor, four temperature fields, perimeters, top_solid_layers, bottom_solid_layers, retract_length, retract_speed, start_gcode, end_gcode), extra_settings (JSON, key→value), timestamps, deleted_at. Details in Printers and Slicer Profiles.

print_logs

Stores manual and automatically generated print history.

Automatic entries are created on print start via SlicerController::sendToPrinter() with status druckt (printing). The scheduler command prints:check later updates them to gedruckt (printed) or fehlgeschlagen (failed), if the printer status allows it.

settings

Singleton table (exactly one row). Fields: id, print_queue_public_access (boolean, default false), print_queue_enabled (boolean, default true), timestamps. Accessed exclusively via Setting::current(). Details in Print Queue.

print_requests

Fields: id, file_id (FK files, cascadeOnDelete()), printer_id (FK printers, restrictOnDelete()), user_id (FK users, nullable, nullOnDelete()null for anonymous submission), submitted_by_name, notes, quality_wish, infill_wish, status (offen|in_druck|erledigt|storniert), sliced_file_id (FK sliced_files, nullable, nullOnDelete()), timestamps. Details in Print Queue.

Render Pipeline

Render jobs run via App\Jobs\RenderFile and the FastAPI worker in render-worker.

Supported render formats:

  • .stl
  • .obj
  • .ply
  • .blend
  • .blend1
  • .scad

.scad is first compiled to STL with OpenSCAD. Blender then renders:

  • 3/4 thumbnail: storage/app/public/renders/{id}.png
  • topdown thumbnail: storage/app/public/renders/{id}-top.png
  • GLB for Three.js: storage/app/public/renders/{id}.glb
  • JSON metadata: storage/app/public/renders/{id}.json

The render worker uses Blender 5.2.0 from the official Blender download, not the Debian package. Reason: older Debian Blender versions can fail on Blender 5 files.

OpenSCAD comes from apt and is only used for .scad.

Targeted STL Conversion

In addition to the thumbnail/GLB, the render worker can export a canonical, triangulated STL from the same geometry (storage/app/public/renders/{id}.stl, File::stl_path) — making an otherwise non-.stl format sliceable in the slicer/plater (see Slicer UI), without the slicer worker itself needing to parse anything beyond .stl.

Deliberately NEVER runs automatically during a routine scan/re-render — only when Laravel explicitly passes include_stl: true to the /render call (RenderRequest.include_stl, default false). Without this separation, every .blend/.blend1/.obj/.ply/.scad file in the inventory would get converted automatically at the next scan — for a historically grown archive (many .blend files aren't print models at all, but e.g. pure Blender scenes), that's neither desired nor cheap (every conversion is an additional, potentially failing Blender export step).

Conversion is triggered exclusively, per file, via the "Convert to STL" button on the file detail page (FilesController::convertToStl(), POST /files/{file}/convert-to-stl, App\Jobs\RenderFile::dispatch($file->id, forceStl: true)), available to any logged-in user (no admin restriction, analogous to rerender). Prerequisite: render_status=done (thumbnail/GLB must already work, otherwise the STL conversion would just repeat the same failure, see File::isStlConvertible()).

Its own status per file, separate from render_status/render_error:

  • stl_status: pending (never requested) → converting (job running) → done (stl_path set) or failed (stl_error).
  • On a failed repeated export (e.g. because the source file changed), stl_path deliberately stays at the last working state instead of being nulled — the file therefore doesn't retroactively disappear from the slicer.
  • Sticky refresh: if an already converted file changes content later (a new scan detects a changed content_hash, files:scan triggers RenderFile::dispatch() without forceStl), the STL still gets automatically updated as well (RenderFile::handle() additionally checks stl_status === 'done') — without conversion needing to be manually re-triggered. For a file that was never converted, on the other hand, stl_status stays pending forever, regardless of how often it's re-scanned/re-rendered.
  • An error during STL export doesn't take down the already successful thumbnail/GLB result of the same render run — both steps run in the same Blender process, but the STL export sits in blender_render.py in its own try/except.

Native .stl files don't need this conversion — the slicer always uses the original file directly there, regardless of render status.

Rotation and Axis Mapping

The preview doesn't load the STL directly, but the GLB generated by Blender.

In PlateViewer.vue, the model is normalized:

object.position.sub(box.getCenter(new THREE.Vector3()));

Rotation and scaling sit on modelRoot:

modelRoot.rotation.set(
  degToRad(item.rotation_x),
  degToRad(item.rotation_z),
  degToRad(-item.rotation_y),
);
modelRoot.scale.set(item.scale_x, item.scale_z, item.scale_y);

Scaling is per axis (scale_x/scale_y/scale_z, see "Data Model" and "Edit Fields per Selected Part" above) and always acts along the part's own, native STL axes, regardless of its current rotation — in Three.js's TRS composition, scaling sits before rotation, exactly as in the slicer worker (transform_stl() scales first, rotates after).

After every sync, the part is lifted via applyFlushHeight() so that its lowest point sits on the bed.

World axes:

UI field Three.js axis Sign
rotation_x X +
rotation_y Z -
rotation_z Y +

Slicer Worker

The slicer worker lives in:

slicer-worker/main.py

It's a FastAPI service with:

  • GET /health
  • POST /slice

Environment variables:

Variable Default Purpose
SOURCE_ROOT /data/source source mount (native .stl originals)
RENDERS_ROOT /data/renders read-only mount of storage/app/public/renders — canonical STLs derived by the render worker for all sliceable non-.stl formats (see Render Pipeline)
OUTPUT_ROOT /data/output G-code output
THUMBNAIL_OUTPUT_ROOT /data/thumbnails G-code thumbnail output
PRUSASLICER_BIN prusa-slicer binary
SLICE_TIMEOUT_SECONDS 900 PrusaSlicer timeout

The worker itself still only reads .stl:

SUPPORTED_EXTENSIONS = {".stl"}

The worker doesn't need to be able to parse other formats (obj/ply/blend/blend1/scad) for this — Laravel already sends a finished .stl per plate item (for non-.stl originals, the stl_path generated by the render worker), plus a root field indicating whether path should be resolved relative to SOURCE_ROOT or RENDERS_ROOT (resolve_source(path, root)).

Input to /slice

Laravel sends:

  • id
  • profile_key
  • profile_ini: the full PrusaSlicer INI text, generated by SlicerProfile::toIni(). The worker writes it itself to a temp file and loads it via --load, see Printers and Slicer Profiles
  • requested_filename
  • bed
  • items
  • options

Example:

{
  "id": 62,
  "profile_key": "ender5max-008",
  "profile_ini": "printer_technology = FFF\nbed_shape = ...\n...",
  "requested_filename": "3dbenchy-0p08mm-ender-5-max-15pct-gyroid.gcode",
  "bed": { "x": 400, "y": 400, "z": 400 },
  "items": [
    {
      "path": "3DBenchy.stl",
      "root": "source",
      "x": 200,
      "y": 200,
      "rotation_x": 0,
      "rotation_y": 0,
      "rotation_z": 0,
      "scale_x": 1,
      "scale_y": 1,
      "scale_z": 1
    }
  ],
  "options": {
    "infill_density": 15,
    "infill_pattern": "gyroid",
    "support_mode": "none",
    "support_style": "organic"
  }
}

STL Transformation

The worker:

  1. resolves the relative path securely against SOURCE_ROOT or RENDERS_ROOT (depending on item.root)
  2. loads binary or ASCII STL
  3. computes the bounding box
  4. centers the geometry on the bounding box's midpoint
  5. scales per axis (scale_x/scale_y/scale_z)
  6. rotates analogously to the Three.js preview
  7. lifts the model to Z=0
  8. shifts it to item.x and item.y
  9. writes a temporary ASCII STL per part

The axis conversion in the worker:

tx, ty, tz = x, z, -y

The rotations are then applied in the order matching Three.js's Euler composition XYZ. The result is mapped back onto slicer axes:

return tx, tz, ty

PrusaSlicer Invocation

The worker calls PrusaSlicer roughly like this:

prusa-slicer \
  --export-gcode \
  --load /profiles/<profile>.ini \
  --output /data/output/<id>-<filename>.gcode \
  --dont-arrange \
  --merge \
  --fill-density <n>% \
  --fill-pattern <grid|gyroid> \
  [support options] \
  item-1.stl item-2.stl ...

Important:

  • --dont-arrange is crucial so PrusaSlicer doesn't rearrange the already computed XY coordinates.
  • --merge ensures the passed temporary STL files are sliced as a shared plate.
  • Transformations don't happen via PrusaSlicer CLI transform flags, but beforehand via custom STL geometry output.

G-code Metadata

After successful slicing, the worker reads from the G-code:

  • estimated print time
  • filament usage in mm
  • filament usage in g

It also computes a SHA-256 over the G-code.

G-code Thumbnails

The worker renders a simple plate preview from the transformed temporary STLs:

  • 96x96 PNG for the printer/G-code thumbnail
  • 300x300 PNG for the Vault UI

The PNGs are embedded as base64 comment blocks in the G-code:

; THUMBNAIL_BLOCK_START
; png begin 96*96 ...
; ...
; png end
; THUMBNAIL_BLOCK_END

The thumbnail meta lines in the G-code are also adjusted:

; thumbnails = 96x96/PNG,300x300/PNG
; thumbnails_format = PNG

Routes

The app uses classic Laravel web routes with Inertia views.

Main Pages

Route Controller Purpose
GET / DashboardController@index dashboard with statistics
GET /gallery GalleryController@index gallery and search
GET /duplicates DuplicatesController@index duplicates view
GET /slicer SlicerController@index slicer UI
GET /trash TrashController@index trash
GET /tags TagController@index tags
GET /projects ProjectController@index projects

Slicer

Route Purpose
GET /slicer show the slicer UI
POST /slicer create a slicing job
GET /slicer/{slicedFile} G-code detail page
GET /slicer/{slicedFile}/download download the G-code
POST /slicer/{slicedFile}/print upload G-code and start the print
DELETE /slicer/{slicedFile} delete a single G-code version from the vault (admin only)
DELETE /slicer delete all completed G-code versions from the vault (admin only)

Files

Route Purpose
GET /files/{file} file detail
GET /files/{file}/original serve the image file directly
POST /files/{file}/rerender re-queue a render job
POST /files/{file}/move move a file within the source mount
POST /files/{file}/copy copy a file
DELETE /files/{file} move a file to .trash/ and soft-delete it (admin only)
POST /files/{file}/tags attach a tag
DELETE /files/{file}/tags/{tag} remove a tag (admin only)
POST /files/{file}/project assign a project
POST /files/{file}/print-logs create a manual print log

Bulk, Scan, Logs

Route Purpose
POST /scan queue a scan job
GET /scan/status scan status for the frontend
POST /files/bulk/delete move several files to the trash (admin only)
POST /files/bulk/move move several files
POST /files/bulk/copy copy several files
POST /files/bulk/project set a project
POST /files/bulk/tags set a tag
PATCH /print-logs/{printLog} correct an automatic print log
DELETE /print-logs/{printLog} delete a print log (admin only)
DELETE /trash/{fileId} permanently delete a file from the trash (admin only)
POST /trash/empty empty the trash completely (admin only)
DELETE /tags/{tag} delete a tag (admin only)
DELETE /projects/{project} delete a project (admin only)

For the full list of all admin-restricted delete endpoints, see Delete Permissions above.

Tests

Feature tests for authentication:

  • tests/Feature/Auth/LoginTest.php
  • tests/Feature/Auth/LoginRateLimitTest.php
  • tests/Feature/Auth/TwoFactorChallengeTest.php
  • tests/Feature/GuestCannotAccessProtectedRoutesTest.php (architecture test: checks all registered routes against an explicit allowlist instead of maintaining individual route tests)
  • tests/Feature/MustChangePasswordTest.php
  • tests/Feature/Admin/AdminMiddlewareTest.php
  • tests/Feature/Admin/LastAdminProtectionTest.php
  • tests/Feature/Admin/UserCreationTest.php
  • tests/Feature/Admin/PrinterCrudTest.php (create, edit, delete lock when profiles reference it, successful delete without references)
  • tests/Feature/Admin/SlicerProfileCrudTest.php (create including extra_settings/toIni() roundtrip, edit, delete)
  • tests/Feature/PrintQueueTest.php (guest redirect when not public, 404 for everyone when print_queue_enabled=false, submitting as guest/logged-in, .blend upload gets rejected, rate limit kicks in, infill wish is pre-filled when opened in the slicer, taking it into the slicer links sliced_file_id/sets status='in_druck')
  • tests/Feature/Admin/SettingsTest.php (admin can toggle print_queue_enabled/print_queue_public_access, non-admin gets 403)
  • tests/Feature/ExampleTest.php (Laravel default, adapted to the new login requirement)
  • tests/Unit/ExampleTest.php (Laravel default, unchanged)

Important: phpunit.xml sets force="true" on the relevant <env> entries — otherwise Docker container environment variables would override PHPUnit's <env> values, and php artisan test would connect to the real production DB instead of an isolated test DB.

For the original (non-auth) part of the app, there are still no dedicated tests for:

  • slicer request validation
  • STL transformation
  • rotation/axis mapping
  • print bed boundaries
  • collision logic
  • PrinterDriver behavior
  • render worker responses

Sensible next tests:

  1. Unit test for SlicerProfile::toIni() (newline roundtrip, bed_shape derivation).
  2. Feature test for POST /slicer.
  3. Python test for rotate_vertex() and transform_stl().
  4. Regression test with a small asymmetric STL for X/Y/Z rotation.
  5. Test for full XY bed boundaries after transformation.

Important Files

Path Purpose
deploy/docker-compose.yml production Docker stack
deploy/Dockerfile PHP/Apache app image
deploy/docker/entrypoint.sh app start, Composer, migrations, caches
deploy/docker/watch-source.sh inotify watcher for the source mount
deploy/docker/apache-vhost.conf Apache vhost and public-only hardening
config/slicer.php only worker_url/supported_extensions now (printers/profiles live in the DB)
app/Models/Printer.php printer model, encrypted:array driver_config, bed() helper
app/Models/SlicerProfile.php slicer profile model, toIni() generator
app/Http/Controllers/Admin/PrinterController.php admin backend: printer management
app/Http/Controllers/Admin/SlicerProfileController.php admin backend: profile management
config/filesystems.php local/public/source disks
routes/web.php web routes
app/Http/Controllers/SlicerController.php slicer UI, job creation, download, print start
app/Jobs/SliceFile.php Laravel queue job to the slicer worker
app/Jobs/RenderFile.php Laravel queue job to the render worker, including targeted STL conversion (forceStl)
app/Http/Controllers/FilesController.php file detail, re-render, targeted STL conversion (convertToStl())
app/Models/File.php scopeSliceable()/isStlConvertible() — determine slicer selectability and convertibility
slicer-worker/main.py FastAPI slicer, STL transformation, PrusaSlicer CLI
render-worker/main.py FastAPI render wrapper, including optional STL export (include_stl)
render-worker/blender_render.py Blender script for render, GLB, metadata, and optional STL export
resources/js/Pages/Slicer/Index.vue slicer UI
resources/js/Components/PlateViewer.vue Three.js print bed
resources/js/Components/PlatePreviewSvg.vue topdown plate preview
app/Services/PrinterDrivers/* printer abstraction and implementations (including PluginPrinterDriver)
app/Services/VaultExport.php shared export/import file format (marker + checksum) for slicer profiles and driver plugins
app/Models/DriverPlugin.php driver plugin model
app/Http/Controllers/Admin/DriverPluginController.php admin backend: plugin management, export/import, /validate proxy
driver-runner/main.py isolated FastAPI service, executes plugin code per call in a throwaway subprocess
driver-runner/_entry.py fixed execution harness for a single plugin call
driver-runner/examples/example_http_driver.py reference example for the plugin contract
app/Services/FileOperations.php safe file operations within the source mount
app/Console/Commands/ScanFiles.php source scan
app/Console/Commands/CheckPrints.php print-status reconciliation
app/Providers/FortifyServiceProvider.php Fortify configuration: views, rate limiters, authenticateUsing
config/fortify.php activated Fortify features, password_timeout
app/Http/Middleware/EnsureUserIsAdmin.php middleware alias admin
app/Http/Middleware/EnsurePasswordIsChanged.php middleware alias password.change
app/Models/Group.php group model (is_admin)
app/Support/LastAdminGuard.php protection against removing/demoting the last admin
app/Http/Controllers/Admin/UserController.php admin backend: user management
app/Http/Controllers/Admin/GroupController.php admin backend: group management
app/Http/Controllers/Auth/ForcedPasswordChangeController.php mandatory password change on first login
app/Notifications/AccountCreated.php mail with credentials for newly created users
app/Console/Commands/EnsureAdminUser.php app:ensure-admin-user, idempotent initial creation of an admin
resources/js/composables/useConfirmsPassword.js proactive password confirmation before password.confirm actions
resources/js/Components/ConfirmPasswordModal.vue modal for password confirmation
resources/js/Layouts/AdminLayout.vue layout for /admin/*
routes/queue.php print-queue routes, registered independently (not part of routes/web.php's auth group)
app/Http/Middleware/EnsureQueueAccessAllowed.php middleware alias queue.access, replaces auth depending on Setting::print_queue_public_access
app/Models/Setting.php singleton settings model, Setting::current()
app/Models/PrintRequest.php print-queue request model
app/Http/Controllers/PrintQueueController.php board, submission (upload + validation), status/delete
app/Http/Controllers/Admin/SettingsController.php admin backend: singleton settings
resources/js/Layouts/PrintQueueLayout.vue its own lean layout, works even without a logged-in user
resources/js/Pages/PrintQueue/Index.vue queue board
resources/js/Pages/PrintQueue/Create.vue submission form

Development

Local standard commands from composer.json:

composer install
npm install --ignore-scripts
npm run build
php artisan migrate

Dev mode:

composer run dev

Dev mode starts:

  • Laravel dev server
  • queue listener
  • Pail logs
  • Vite

In production, however, Docker Compose is authoritative (see Self-Hosting).

License

GPL-3.0-or-later. Full license text in LICENSE.