What Stirling PDF needs from the machine
Stirling PDF is a Java server with a browser interface and a REST API that wraps LibreOffice, Tesseract, OCRmyPDF, Ghostscript, QPDF and other tools. Its Performance page warns that one large PDF can take many times its file size in memory while it is processed, and gives this sizing:
| Users | CPU | Memory | Disk |
|---|---|---|---|
| 1-10 | 2-4 cores | 4 GB | 10 GB |
| 10-50 | 4-8 cores | 8-16 GB | 50 GB, SSD recommended |
| 50+ | 8+ cores | 16 GB or more | 100 GB+, SSD |
The workload, not the head count, moves the numbers. The docs rate OCR as very high on CPU and high on memory, Office conversions as one core per conversion, and PDF to image as very high on memory; merge and split stay low on CPU. The Docker image sizes the Java heap at up to 70% of the container's memory limit and, without a limit, from the host's total RAM, so always set one. When a server runs out of memory the container stops and every running job fails.
Image variants
| Tag | Login and admin settings | Contents | Suggested memory |
|---|---|---|---|
| latest | Yes | LibreOffice, Tesseract, OCRmyPDF, WeasyPrint, Calibre, Ghostscript, QPDF, ImageMagick | 4 GB |
| latest-fat | Yes | latest plus the bundled AI engine, off until switched on | 6 GB, more with AI on |
| latest-ultra-lite | No | Core PDF tools only: no OCR, Office, HTML or ebook conversion | 2 GB |
Every image is built for amd64 and arm64. The docs say to start with latest; switching variants is a tag change and a container recreate, and the volumes carry over. For production they recommend a version tag (the release number alone, or with -fat or -ultra-lite appended; numbers on the GitHub releases page) so upgrades happen when you choose. If docker.stirlingpdf.com is unreachable, the same images are on Docker Hub (stirlingtools/stirling-pdf) and GitHub (ghcr.io/stirling-tools/stirling-pdf).
The ultra-lite image has no users and no sign-in at all: anyone who reaches it can use it. Keep it on 127.0.0.1 or behind a proxy that authenticates.
Which machine to put it on
A dedicated server gives the container every core and all the memory: no neighbour competes for RAM while an OCR job peaks, and the uploads and saved files sit on disks only you use. You get root, a reinstall from the control panel, and on every racked single-, dual- and quad-socket server an out-of-band console (HPE iLO, Dell iDRAC or a Supermicro BMC) for the day a firewall rule locks you out. The servers and the network are ours, on AS19624. Racked machines are handed over in about 30 minutes once payment is approved; a built-to-order line takes 4 to 24 hours.
- A handful of users, mostly merge, split and sign: an x86 Moonshot cartridge with 8-32 GB covers the docs' first row (not the ARM X-Gene cartridge: it installs only Ubuntu 16.04 or 18.04). The catalogue describes the cartridges as one service per machine, which is what this is.
- A department running OCR and Office conversions: a single- or dual-socket rack server, sized memory first. That class also carries the console.
- arm64: the images are multi-arch, so the 64-core Ampere Altra on the ARM dedicated servers page, which installs Ubuntu 22.04 or newer for aarch64, runs the same container. It is built to order and takes 1 to 2 weeks from payment approval, so ask in chat which out-of-band access a build includes.
Installing with Docker Compose
Prerequisites: Ubuntu, Docker Engine 28 or newer with the Compose plugin, and a login of your own with sudo. Docker opens a mapping that names no address on every host address, IPv4 0.0.0.0 and IPv6 [::], and that traffic bypasses ufw's rules on IPv4, and on IPv6 too when Docker's own IPv6 networking is on. Binding to 127.0.0.1 sidesteps it: on Engine 28 and newer such a port answers local connections only. Run the following as root (sudo -i first). ufw comes up before any service listens, and IPV6 is set before the first rule:
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH
ufw --force enable
mkdir -p /opt/stirling-pdf/stirling-data/{configs,logs,customFiles,pipeline,storage,tessdata}
chown -R 1000:1000 /opt/stirling-pdf/stirling-data
cd /opt/stirling-pdf
umask 077
cat > stirling.env <<EOF
SECURITY_INITIALLOGIN_USERNAME='pdfadmin'
SECURITY_INITIALLOGIN_PASSWORD='$(openssl rand -base64 24)'
SYSTEM_ENABLEANALYTICS='false'
EOF
chmod 600 stirling.env
cat stirling.env
The container runs as UID and GID 1000, hence the chown (or set PUID and PGID to the folders' owner). The password is generated on the server rather than typed, so it never lands in shell history; cat stirling.env shows it once. SECURITY_INITIALLOGIN_* creates the first admin instead of admin / stirling and is only read while the user database is empty. SYSTEM_ENABLEANALYTICS=false stops anonymous usage data, which the docs say never includes document contents. Next to it goes docker-compose.yml, the documented file with the port bound to loopback and the env file added:
services:
stirling-pdf:
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest
container_name: stirling-pdf
ports:
- '127.0.0.1:8080:8080'
env_file: stirling.env
volumes:
- ./stirling-data/configs:/configs
- ./stirling-data/logs:/logs
- ./stirling-data/customFiles:/customFiles
- ./stirling-data/pipeline:/pipeline
- ./stirling-data/storage:/storage
- ./stirling-data/tessdata:/usr/share/tessdata
mem_limit: 4g
restart: unless-stopped
docker compose up -d brings the container up. If the page never answers, docker logs stirling-pdf shows why; the first error usually names the cause. Raise mem_limit to 6g for latest-fat, as the image table suggests.
First sign-in, before anything is public
- On your workstation, logged in as yourself rather than a service account:
ssh -L 28080:127.0.0.1:8080 -N you@server. - Point a browser at
http://localhost:28080and sign in aspdfadminwith the generated password. - If you skipped the env file, the account is
admin/stirling; the first sign-in forces a new password of at least 8 characters. Do it here, through the tunnel. - Answer the telemetry question if it appears (it does when analytics is unset), and check Settings, Server, Sign-in & security: lockout after 5 failed attempts for 120 minutes is the default.
- Delete the password line with
sudo sed -i '/INITIALLOGIN_PASSWORD/d' /opt/stirling-pdf/stirling.env, then recreate the container:cd /opt/stirling-pdf && sudo docker compose up -d. The setting is ignored once users exist, and an environment variable is visible to anyone who can rundocker inspect.
HTTPS through nginx on the host
The production checklist asks for a proxy that forwards to port 8080 with the X-Forwarded-Proto, -Host and -Port headers, accepts uploads as large as system.fileUploadLimit (2000 MB by default; nginx allows 1 MB unless told otherwise), turns off buffering and allows 30-minute reads for long runs. Its nginx example, with IPv6 listeners added and your own certificate paths:
server {
listen 80;
listen [::]:80;
server_name pdf.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name pdf.example.com;
ssl_certificate /etc/ssl/certs/pdf.example.com.crt;
ssl_certificate_key /etc/ssl/private/pdf.example.com.key;
client_max_body_size 2000M;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_read_timeout 1800s;
proxy_send_timeout 1800s;
}
}
nginx on the host is an ordinary process, so ufw blocks it on IPv4 and IPv6 alike until you run ufw allow 80,443/tcp. Add SYSTEM_FRONTENDURL and SYSTEM_BACKENDURL with your https address to stirling.env (quoted, like the other values): invite emails, share links and SAML sign-in use them. For a sub-path such as /pdf, set SYSTEM_ROOTURIPATH=/pdf and forward the prefix unchanged. The Nginx Proxy Manager page covers a GUI alternative; the same header and body-size rules apply.
settings.yml and what overrides it
configs/settings.yml is created on first start and rebuilt from the shipped template on every start, keeping your values. Three behaviours catch people out:
- A commented-out entry comes back with its default. To turn something off, change its value.
- Keys not in the shipped file are removed, and a file under 31 lines is treated as invalid, saved as a timestamped
.bakcopy and replaced with defaults. Short overrides, port and HTTPS options go incustom_settings.yml. - Precedence runs environment variables, then
custom_settings.yml, thensettings.yml(including changes made in the app), then built-in defaults. A value set instirling.envcannot be changed from the admin pages until you remove the variable.
Any key maps to a variable: join the levels with underscores in capitals, so security.enableLogin becomes SECURITY_ENABLELOGIN. Keep it true on anything reachable from outside; with login off, anyone who can reach the server can use it.
Updates, backups and monitoring
Copy configs/ aside first: the docs ask for a configs backup before each update, and the V1-to-current upgrade rewrites settings.yml and upgrades the database in place. If you pinned a release, change the tag in docker-compose.yml to the new one. Then, in /opt/stirling-pdf, run sudo docker compose pull followed by sudo docker compose up -d.
Back up the whole configs/ folder (settings, generated keys, the built-in database, and its nightly copies in configs/backup/db/), storage/ with the files users saved, and customFiles/ if you use branding or signatures. Copy them off the machine; a backup on the same disks dies with it. Point health checks at /api/v1/info/status, which needs no sign-in. The same machine can also run an archive such as Paperless-ngx next to it, each container with its own memory limit.
The licence and the plans
The README sums it up as "Stirling PDF is open-core." The repository's LICENSE file is MIT with carve-outs: it names directories, among them app/proprietary/, engine/ and several frontend folders, that are "licensed under the license defined in" their own LICENSE file, and states that "Content outside of the above mentioned directories or restrictions above is available under the MIT License". The licence in app/proprietary/ begins: "Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License."
Separately, the server docs describe plans: "A self-hosted server runs on the Free plan until you add Team or Enterprise." Free covers 5 users, every PDF tool, the REST API and OAuth2/OIDC single sign-on. On every plan but Enterprise, Processor automation and AI tools draw on a monthly processing allowance, and PDF tool calls made with an API key count against it too unless the server has a Team licence key, so heavy API use on Free is not unmetered. Team adds an external database, clustering, S3 or database file storage and Prometheus metrics; Enterprise adds SAML, audit logging and offline activation. Licences need a login build; ultra-lite has no plans. The Plans page does not mention the app/proprietary/ licence, so read the licence files and the Plans page side by side against your own use before relying on the server in production.
Questions
What is the default Stirling PDF login?
On a login build with an empty user database, the server creates admin with the password stirling and makes you set a new one of at least 8 characters at first sign-in. Setting SECURITY_INITIALLOGIN_USERNAME and SECURITY_INITIALLOGIN_PASSWORD before the first start creates your own admin instead. Do that first sign-in via a tunnel, with the port still bound to 127.0.0.1.
Which port does the container use?
8080 inside the container. To serve on another host port, change the host side of the mapping, for example 127.0.0.1:9000:8080. The proxy then points at the new port.
Can I run it without any login?
Yes: SECURITY_ENABLELOGIN=false, or use the ultra-lite image, which has no login at all. Anyone who can reach the server can then use it, and features that need accounts, such as server file storage, turn off. On a machine with a public address, keep such an instance on loopback or behind an authenticating proxy.
Why does a large upload fail behind my proxy?
The docs name the proxy's body limit as the cause: nginx accepts 1 MB by default, and the failure shows up as a downloaded .htm file or an error. Raise nginx's client_max_body_size to the app's system.fileUploadLimit (2000 MB by default) and lengthen the read and send timeouts.
Why is memory use high when nothing is running?
The image sizes the Java heap from the container's memory limit, or from all the host's RAM when no limit is set. Set mem_limit. If the container stops under load, the docs suggest JAVA_CUSTOM_OPTS with -XX:MaxRAMPercentage=50 to leave more room for LibreOffice and Tesseract.