Self-hosted web app for a homelab or small office: upload a PDF, PNG or JPEG, pick a printer and a preset, print. Talks IPP directly to the printer, so there is no CUPS to run or babysit.
- Capability-driven. The printer's own
Get-Printer-Attributesresponse generates the option editor, validates every job, and is diffed on each re-fetch so changes are flagged instead of silently breaking presets. The only hand-maintained table is labels and widget types. - Shared presets. Admins publish named presets for everyone; users keep personal ones. Constraint and resolver data (PWG 5100.13) is honoured when the printer publishes it, with a one-click "apply suggested fix".
- Multi-user. Local accounts, admin and user roles, per-user job history.
- No spooler. Jobs retry with backoff when the printer is unreachable, and wait it out when it says it is busy with another job; real rejections surface the printer's own IPP status and message.
Status: milestones M1 to M3 of print-server-plan.md are built. M0 (the spike against the office
Toshiba e-STUDIO) has its capabilities captured as a fixture and Validate-Job checked; a real print
run is still to be done on-site. Development targets ippeveprinter.
The standard install is Docker Compose with docker-compose.yml, which
pulls ghcr.io/bevanjkay/printmax:latest (built by CI from main; releases are tagged vX.Y.Z):
docker compose up -dTo build and run from source instead, add the development override:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --buildOpen http://localhost:8080. The first visit asks you to create the admin account. Then, under
Printers, add a printer by IPP URI (usually ipp://<host>/ipp/print) or scan for one.
Every variable has a default; set them in the environment or a .env file next to the compose file.
| Variable | Default | Purpose |
|---|---|---|
PRINTMAX_IMAGE |
ghcr.io/bevanjkay/printmax:latest |
Image the compose file runs |
PORT |
8080 |
HTTP port |
DATA_DIR |
/data in the container |
SQLite database and uploads |
RETENTION_DAYS |
7 |
Delete uploaded files this long after the job finishes |
POLL_INTERVAL_MS |
3000 |
How often job state is polled and retries are attempted |
CAPS_REFRESH_HOURS |
24 |
Re-fetch printer capabilities older than this; 0 disables |
DISCOVERY_TIMEOUT_MS |
3000 |
How long a network scan listens for DNS-SD answers |
MAX_UPLOAD_MB |
200 |
Upload size limit |
MAX_POSTSCRIPT_MB |
512 |
Ceiling on the PostScript one conversion may produce; image-heavy pages cost a few MB each, whatever the PDF weighed |
STORED_DIR |
/data/stored |
Library documents, kept until deleted |
SETUP_TOKEN |
generated | Required by the first-run setup page; a random one is printed in the log when unset |
TRUST_PROXY |
false |
Trust X-Forwarded-* from a reverse proxy; set false when clients reach the app directly, or a CIDR list |
The app runs as the unprivileged node user (uid 1000). The container starts as root only to
hand /data to that user, so a bind-mounted host directory or a volume created by an older image
works without preparation. If you start it with --user instead, the ownership step is skipped
and /data must already be writable by that uid; the server says so plainly and exits if not.
"Scan" browses DNS-SD (_ipp._tcp / _ipps._tcp). Multicast does not cross the Docker bridge,
so inside Docker it only works with network_mode: host (see the commented block in
docker-compose.yml). Manual URI entry is the reliable path; ippfind on any machine on the printer's
network prints the URI.
The app ignores X-Forwarded-* headers by default. Behind Caddy, Traefik, nginx or a Cloudflare
Tunnel, set TRUST_PROXY to the proxy IP or CIDR (a comma-separated list is accepted), and prevent
clients from reaching the backend directly. Only use true when every path to the backend passes
through a proxy that sanitizes forwarded headers. Trusted HTTPS requests receive a Secure session cookie. Two
things to check on the proxy: allow request bodies up to MAX_UPLOAD_MB (nginx:
client_max_body_size), and forward X-Forwarded-Proto. Nothing in the app needs a path prefix
or WebSockets.
-
Every printing, preset and printer route needs a signed-in session; sessions are random tokens stored hashed,
HttpOnly,SameSite=Lax, andSecurebehind HTTPS. -
Sign-in and setup are rate limited to 10 attempts per client IP per minute (from trusted
X-Forwarded-ForwhenTRUST_PROXYis configured). -
The first-run setup page needs the token printed in the log at startup, so an instance exposed before its admin exists cannot be claimed by a passer-by.
-
Responses carry a self-only Content Security Policy and the usual Helmet headers; the app loads nothing from outside itself.
-
For internet exposure an identity-aware proxy (Cloudflare Access or similar) in front of the sign-in page is still the strongest cheap control.
-
Sessions are random tokens stored server-side; nothing to configure and no signing secret.
-
Passwords are hashed with scrypt.
-
Printer credentials (for devices that require HTTP basic auth on IPP) are stored unencrypted in the SQLite database. Treat the data volume accordingly.
-
IPPS connections verify certificates; private certificates need an explicitly configured trust root.
IPPS/HTTPS printer connections verify the certificate and hostname. For a private CA or self-signed
printer certificate, mount the trusted PEM certificate read-only and set NODE_EXTRA_CA_CERTS to
its container path (for example /certs/printer-ca.pem) in the service environment, then restart.
Obtain that certificate through a trusted channel; its subject alternative name must match the
printer URI. Existing self-signed IPPS printers need this configuration after upgrading.
PostScript mode invokes Ghostscript's PDF interpreter directly and stops conversion after 60 seconds
or MAX_POSTSCRIPT_MB of generated output. Conversion runs at the PPD's *DefaultResolution, so a
page ps2write has to rasterise costs what the engine can image rather than its 720 dpi default. Cancellation terminates an active conversion. These bounds are separate
from the upload size limit. Temporary upload files abandoned by a crash are removed after 24 hours;
in-flight uploads are excluded.
Shared library entries follow only shared presets. Selecting a personal preset when saving a shared entry saves a snapshot. If a linked preset becomes private or moves to another printer, the entry uses its previously saved snapshot and stops exposing the live preset's name and settings.
The TLS regression test uses the openssl CLI. Printer and conversion tests also use
ippeveprinter and gs when installed.
pnpm install
pnpm test # unit and API tests, plus an end-to-end run against ippeveprinter if installed
pnpm dev # API on :8080 (serves dist/client if built)
pnpm dev:client # Vite dev server with /api proxied to :8080Capture a printer's attributes as a fixture:
pnpm dump-caps ipp://printer/ipp/print [username password] > fixtures/my-printer.jsonSome finishing never appears in a printer's IPP attributes because the vendor implements it as
private PostScript commands in its driver; on the Toshiba e-STUDIO that covers folding,
saddle stitch and booklet imposition. printmax can send jobs the way that driver does. Under
Printers, upload the device's PPD (on a Mac with the printer installed it is in /etc/cups/ppd/)
and switch the printer to PostScript via PPD. From then on:
- the print form and presets use the PPD's own options (
ppd:<Key>in the option map) plus copies, in place of the IPP attributes; - each job is a PDF converted with Ghostscript and wrapped in the PPD's PJL header with every
option's snippet in
*OrderDependencyorder, exactly as CUPS emits it; - copies are asked for inside the PostScript (
NumCopies), not as an IPP attribute, since a printer in this mode reads the document and ignores the attributes; - PDF is the only accepted upload while the mode is on;
- Fit to paper (on by default) scales each page to the chosen paper, the way a driver does. Turn it off to print every page at the size it was made, centred on the sheet and turned a quarter when that is the only way it fits; the form then warns when a page is larger than the paper and would be cut;
- pages go out edge to edge, so the only clipping is the printer's own unprintable strip. Turn on Keep printer margins (per job or in a preset) to shrink each page uniformly into the PPD's imageable area instead, the way the vendor driver does.
It is opt-in per printer and reversible; plain IPP remains the default. The proof for the
Toshiba was an 8-page A5 booklet that came out imposed and folded (fixtures/booklet-8-pages.pdf).
The Library keeps documents for good, each paired with a preset: the weekly bulletin, the giving
envelope, the welcome card. Printing one is a click plus a copies count. Entries can be shared
with everyone (admins) or personal, like presets, and any job whose file is still on the server
can be kept from the Jobs list with Keep. Files live under /data/stored and are never swept
by retention; the preset is followed by reference, so improving it improves every document that
uses it.
Documents are filed into groups — "Sunday", "Kids", "Office". Admins keep each printer's group list, in the order they choose, from Groups on the Library page or with + Add new while filing a document; everyone else picks from that list. The library shows one collapsible section per group with a count, collapsed to start with and ungrouped documents last, so a long library reads as a handful of headings. Deleting a group keeps its documents and leaves them ungrouped. The search box above the list covers the name, the file, the group and the preset, and opens the sections holding whatever it found.
The Presets page exports a printer's presets as JSON and imports the same format; presets whose
options the printer rejects are listed rather than failing the whole file. A separate converter,
batchoutput-export, turns Zevrix BatchOutput PDF presets into these files, one per preset; it
keeps what a printer takes over IPP (paper size, tray, paper type, duplex, colour, corner staples,
orientation, copies) and records the rest in each preset's description.
The preset editor's "Check with printer" button sends the options as an IPP Validate-Job, so the device itself confirms it would accept them without printing anything. The capability overrides editor suggests registered IANA/PWG values the printer did not report; use the same check to confirm a value before relying on it. A rejection is reliable; a pass is only as good as the device's own checking (the Toshiba e-STUDIO accepts any job attribute and only validates the document format).
src/server/ipp/— RFC 8010 codec, HTTP transport, operations, option typing and validation, constraint/resolver evaluationsrc/server/— SQLite (node:sqlite), auth and sessions, printers, presets, jobs, worker, capability diffing, DNS-SD discovery, Fastify appsrc/shared/— DTOs, IPP enum tables, and the attribute label/widget tablesrc/client/— React front end, served static by the server in productionfixtures/— verbatim Get-Printer-Attributes dumps used as test data
ippnpm package replaced by an in-house codec: the package was last published in 2022 and declaresengines.node < 4. The codec is ~400 lines and round-trip tested.better-sqlite3replaced by Node's built-innode:sqlite(release candidate in Node 24, no flag): same synchronous API, no native build in the Docker image.- Printers carry optional
username/passwordcolumns; the target device advertises "IPP 2.0 with authentication". - A
sessionstable and acaps_changestable were added for login and re-fetch diffing.