# Can I self-host Adobe Acrobat?

**YES** — it's called Stirling-PDF. ONE COMMAND setup · ~10 minutes to running · 2 GB RAM minimum · $24.99/mo you stop paying ($299.88/yr on the Acrobat Standard plan).

Stirling-PDF authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/adobe-acrobat/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Stirling-PDF 2.14.2 on that server, reachable at https://<DOMAIN>, behind the
existing Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they
answer. Its A record must already point at this server. This is a JVM shipping
LibreOffice, Calibre and Tesseract inside the image, so it needs 2048 MB of RAM available
and 10 GB free on /srv. The 2.14.2 image is published for amd64 and arm64. Measure all
four first:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and
stop: the image is several GB before a document is uploaded, and a JVM out of heap during
an OCR pass looks like a random failure. If `dig +short` prints nothing, print that and
stop: Caddy cannot certify a hostname that does not resolve.

## 2. Layout

The image defaults PUID and PGID to 1000 and drops to that user, so /configs belongs to
1000.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/stirling-pdf /srv/stirling-pdf/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/stirling-pdf/config
ls -la /srv/stirling-pdf
```

Assert: `ls -la` shows `backups` owned by the login user and `config` owned by `1000`.
Everything lives under /srv/stirling-pdf and nothing is written outside it.

## 3. Secrets

One secret: the first-login credential for the `admin` account, generated on the server.
Do not print it, do not repeat it in your summary, and do not put it in any log line.

This install runs with login enabled. That is the one decision in this prompt: with
`SECURITY_ENABLELOGIN=false` anyone who finds the hostname can push documents through the
user's server. Login enabled ships a default account, so the generated value below
replaces it before the container starts.

```bash
umask 077
cat > /srv/stirling-pdf/.env <<EOF
DISABLE_ADDITIONAL_FEATURES=false
SECURITY_ENABLELOGIN=true
SECURITY_INITIALLOGIN_USERNAME=admin
SECURITY_INITIALLOGIN_PASSWORD=$(openssl rand -base64 24)
SYSTEM_DEFAULTLOCALE=en-GB
SYSTEM_MAXFILESIZE=100
SYSTEM_GOOGLEVISIBILITY=false
METRICS_ENABLED=false
EOF
chmod 600 /srv/stirling-pdf/.env
umask 022
ls -l /srv/stirling-pdf/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user their username is `admin`,
that they read the generated value once with
`sudo grep SECURITY_INITIALLOGIN_PASSWORD /srv/stirling-pdf/.env`, and that Stirling-PDF
makes them choose a new one at the first sign-in. `SYSTEM_GOOGLEVISIBILITY=false` asks
search engines not to index the instance; `METRICS_ENABLED=false` turns off the Prometheus
endpoint, which nothing here scrapes.

## 4. compose.yml

```bash
cat > /srv/stirling-pdf/compose.yml <<'EOF'
# Stirling-PDF · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and ports .... https://docs.stirlingpdf.com/Installation/Docker%20Install
#   login settings ..... https://docs.stirlingpdf.com/Configuration/System%20and%20Security/
#
# One container, no database process: the user table is an embedded H2 file under
# /configs, so that directory plus the .env is the whole install. The image runs
# as uid 1000, hence the ownership in step 2. Tag and digest are the 2.14.2
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  stirling-pdf:
    image: stirlingtools/stirling-pdf:2.14.2@sha256:7ed4d9681d18e4fbc3aa6a63647c4b5c2bcc4b75841df7c05d7e3d2320f5c9a1
    container_name: stirling-pdf
    restart: unless-stopped
    env_file: /srv/stirling-pdf/.env
    volumes:
      # The H2 user database and the generated server certificate live here.
      # One mount, so one directory to copy. OCR language packs beyond the
      # bundled English set would need a second mount at /usr/share/tessdata.
      - /srv/stirling-pdf/config:/configs
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8087 never enters the firewall.
      - "127.0.0.1:8087:8080"
EOF
cd /srv/stirling-pdf && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container serves on port 8080 inside itself and 8087
is bound to 127.0.0.1 on the host, so the only route in is Caddy.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by
the real hostname. Copy the file first: a syntax error takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-stirling-pdf
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Stirling-PDF · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https and
# https://docs.stirlingpdf.com/Installation/Docker%20Install
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8087 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy sets no request
	# body limit of its own, so SYSTEM_MAXFILESIZE in .env is the ceiling.
	reverse_proxy 127.0.0.1:8087
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-stirling-pdf, reload, and report what it objected to. Caddy
gets the certificate on the first request and renews it with no cron job.

## 6. Firewall

Two ports open, both Caddy's. These are idempotent, so on a box Prompt Zero configured
they change nothing:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in,
443/udp is HTTP/3. 8087 stays closed: bound to 127.0.0.1, a rule for it would cover
traffic that cannot arrive, and if it appears there a previous run left it, which
`sudo ufw delete allow 8087` fixes. Assert: `ufw status verbose` prints `Status: active`,
shows 80, 443/tcp and 443/udp, and no rule for 8087.

## 7. Start and verify

The first boot is slow, and the image's own health check allows two minutes before it
counts failures. Wait for that health check rather than guessing:

```bash
cd /srv/stirling-pdf
docker compose pull
docker compose up -d
sleep 90
docker inspect --format '{{.State.Health.Status}}' stirling-pdf
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -ci 'stirling'
sudo grep -ci 'stirling$' /srv/stirling-pdf/.env
```

Assert, all four: `docker inspect` prints `healthy`, the first curl prints `200`, the
second prints a number greater than `0` because `Stirling` appears in the served document,
and the last prints `0`, proving the shipped default credential is not in this install.
Print what you received for each. If health is still `starting`, wait 60 seconds and check
again before treating it as a failure. If anything misses after that, stop, run
`docker compose logs --tail 40 stirling-pdf`, and name the earlier step that is the likely
cause. A running container is not success; four asserts passing is. The first screen at
https://<DOMAIN> is a sign-in form asking for a username and a password.

STOP: tell the user to open https://<DOMAIN>, sign in as `admin` with the value from step 3,
set the new password Stirling-PDF demands, and save it. Wait for their confirmation.

## 8. First backup and restore

Take the backup now, before the user relies on the account. Stop first: the H2 user
database copied mid-write is not a backup.

```bash
cd /srv/stirling-pdf
docker compose stop
sudo tar -C /srv/stirling-pdf -czf /srv/stirling-pdf/backups/stirling-pdf-$(date +%F).tar.gz config .env
docker compose start
ls -lh /srv/stirling-pdf/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds,
and `config` plus `.env` is the whole install: uploaded documents are never kept. A backup
on the same disk is not a backup, so run this from the user's machine, not the server:

```bash
mkdir -p ~/backups/stirling-pdf
scp vps:/srv/stirling-pdf/backups/*.tar.gz ~/backups/stirling-pdf/
```

To restore: `docker compose down`, `sudo rm -rf /srv/stirling-pdf/config`,
`sudo tar -C /srv/stirling-pdf -xzf` the archive, then `docker compose up -d`. The account
lives in the H2 file under `config/`. Those four commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/Stirling-Tools/Stirling-PDF/releases. Take a
backup first, then edit the image line in /srv/stirling-pdf/compose.yml to the new tag and
digest. Stirling-PDF migrates the H2 file on the first boot after an upgrade, so wait for
the health check to go green before calling the update done.

```bash
cd /srv/stirling-pdf
docker compose pull
docker compose up -d
docker compose logs --tail 20 stirling-pdf
```

## 10. What will probably go wrong

The first boot. I watched `docker ps` report the container as unhealthy for close to two
minutes and assumed the install had failed, when the JVM was still unpacking LibreOffice
and building its font cache. On a 2 GB box that start took longer than every other step
here combined. If step 7 reports `starting`, read
`docker compose logs --tail 40 stirling-pdf` before changing anything: a boot in progress
prints Spring startup lines, a real failure prints a stack trace or the OOM killer.

## 11. Out of scope

- Do not set `SECURITY_ENABLELOGIN=false`. This instance answers on a public hostname, and
  the unauthenticated mode is for a machine nobody else can reach.
- Do not configure OAuth2 or SAML sign-on. Both need an identity provider registered
  elsewhere, which is the user's decision and not this install's.
- Do not add a /usr/share/tessdata mount for extra OCR languages. English is bundled.
- Do not enable `METRICS_ENABLED` or install Prometheus. Nothing on this box scrapes it.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Stirling-PDF 2.14.2 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP address on the last line.

If you do not: this is the heaviest install in the catalogue, because the image carries a JVM,
LibreOffice, Calibre and Tesseract. Under 2 GB of RAM the first OCR job kills the container and
the failure looks random. An empty last line means the A record does not exist yet: add it at
your DNS provider, wait a minute, and run `dig +short <DOMAIN>` again. Caddy cannot get a
certificate for a hostname that does not resolve, and failed attempts count against a rate
limit you cannot see.

## 2. Layout

The image defaults PUID and PGID to 1000 and drops to that user, so `config` belongs to 1000
and not to you.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/stirling-pdf /srv/stirling-pdf/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/stirling-pdf/config
ls -la /srv/stirling-pdf
```

You should see: `backups` owned by your own username, and `config` owned by `1000`.

If you do not: `config` owned by you instead of `1000` means the second command did not run.
The container will fail to write its user database with a permission error that says nothing
about ownership. Run the second line again on its own.

## 3. Secrets

One secret: the first-login credential for the `admin` account. It is generated here, on the
server, and it goes straight into a file only you can read. Stirling-PDF ships a default
account when login is enabled, and this file is what replaces it before the container starts.

```bash
umask 077
cat > /srv/stirling-pdf/.env <<EOF
DISABLE_ADDITIONAL_FEATURES=false
SECURITY_ENABLELOGIN=true
SECURITY_INITIALLOGIN_USERNAME=admin
SECURITY_INITIALLOGIN_PASSWORD=$(openssl rand -base64 24)
SYSTEM_DEFAULTLOCALE=en-GB
SYSTEM_MAXFILESIZE=100
SYSTEM_GOOGLEVISIBILITY=false
METRICS_ENABLED=false
EOF
chmod 600 /srv/stirling-pdf/.env
umask 022
ls -l /srv/stirling-pdf/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Your Stirling-PDF
username will be `admin`. Read the generated value once with
`sudo grep SECURITY_INITIALLOGIN_PASSWORD /srv/stirling-pdf/.env`. Stirling-PDF makes you
choose a new one at the first sign-in, so the value in this file stops mattering after that.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines one at a time in different shells. Run `chmod 600 /srv/stirling-pdf/.env`
and carry on.

Do not paste the contents of that file, the generated value, or any command output containing
it into this chat window. Nothing in the rest of this guide needs it, and once it is in a
transcript it is somebody else's copy.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/stirling-pdf/compose.yml <<'EOF'
# Stirling-PDF · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and ports .... https://docs.stirlingpdf.com/Installation/Docker%20Install
#   login settings ..... https://docs.stirlingpdf.com/Configuration/System%20and%20Security/
#
# One container, no database process: the user table is an embedded H2 file under
# /configs, so that directory plus the .env is the whole install. The image runs
# as uid 1000, hence the ownership in step 2. Tag and digest are the 2.14.2
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  stirling-pdf:
    image: stirlingtools/stirling-pdf:2.14.2@sha256:7ed4d9681d18e4fbc3aa6a63647c4b5c2bcc4b75841df7c05d7e3d2320f5c9a1
    container_name: stirling-pdf
    restart: unless-stopped
    env_file: /srv/stirling-pdf/.env
    volumes:
      # The H2 user database and the generated server certificate live here.
      # One mount, so one directory to copy. OCR language packs beyond the
      # bundled English set would need a second mount at /usr/share/tessdata.
      - /srv/stirling-pdf/config:/configs
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8087 never enters the firewall.
      - "127.0.0.1:8087:8080"
EOF
cd /srv/stirling-pdf && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/stirling-pdf/.env not found` means step 3 did not write the file,
so go back. `services must be a mapping` means the indentation was lost between the page and
your terminal: run `rm /srv/stirling-pdf/compose.yml` and paste the block again in one go.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-stirling-pdf
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Stirling-PDF · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https and
# https://docs.stirlingpdf.com/Installation/Docker%20Install
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8087 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy sets no request
	# body limit of its own, so SYSTEM_MAXFILESIZE in .env is the ceiling.
	reverse_proxy 127.0.0.1:8087
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-stirling-pdf /etc/caddy/Caddyfile`,
reload, and paste again, checking that the blank line from the second command really landed.
Caddy asks Let's Encrypt for the certificate on the first request to your hostname and renews
it on its own, so there is nothing to schedule.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8087`.

If you do not: a rule for `8087` from an earlier attempt should go, with
`sudo ufw delete allow 8087`. 8087 is bound to 127.0.0.1 by the compose file, so nothing
outside the machine can reach it and a firewall rule for it would cover traffic that cannot
arrive. 80/tcp is there to answer the certificate challenge and redirect to HTTPS, 443/tcp is
the only way in, and 443/udp is HTTP/3.

## 7. Start and verify

The first boot is slow. The image's own health check allows two minutes before it starts
counting failures, so `sleep 90` below is not padding.

```bash
cd /srv/stirling-pdf
docker compose pull
docker compose up -d
sleep 90
docker inspect --format '{{.State.Health.Status}}' stirling-pdf
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/ | grep -ci 'stirling'
sudo grep -ci 'stirling$' /srv/stirling-pdf/.env
```

You should see: `healthy`, then `200`, then a number greater than `0`, then `0`. That last `0`
is the security check: it proves the credential Stirling-PDF ships with is not in your file.

If you do not: `starting` is not a failure yet. Wait 60 seconds and run the `docker inspect`
line again. If it is still not `healthy` after that, run
`docker compose logs --tail 40 stirling-pdf`: a boot in progress prints Spring startup lines,
a real failure prints a stack trace, and a container that vanished was killed for running out
of memory. If the first curl prints `000` or `502`, the certificate is not there yet, so run
`sudo journalctl -u caddy -n 30`.

A container listed in `docker ps` is not proof of anything. The four checks above are.

Now open https://<DOMAIN> in a browser. The first screen is a sign-in form asking for a
username and a password. Sign in as `admin` with the value from step 3, set the new password
it asks you for, and put that new one in your password manager.

## 8. First backup and restore

Do this before you rely on the account, so you find out now whether it works. The stop matters:
the H2 user database copied mid-write is not a backup.

```bash
cd /srv/stirling-pdf
docker compose stop
sudo tar -C /srv/stirling-pdf -czf /srv/stirling-pdf/backups/stirling-pdf-$(date +%F).tar.gz config .env
docker compose start
ls -lh /srv/stirling-pdf/backups/
```

You should see: one `.tar.gz` file, tens of kilobytes on a fresh install. The site is down for
a few seconds while this runs, which is the price of a backup that is actually consistent.

If you do not: `tar: config: Cannot open` means the `cd` did not happen. A size of `45` bytes
means tar wrote an empty archive because the paths were wrong, so check
`sudo ls /srv/stirling-pdf/config` before you trust it.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not
on the server:

```bash
mkdir -p ~/backups/stirling-pdf
scp vps:/srv/stirling-pdf/backups/*.tar.gz ~/backups/stirling-pdf/
```

You should see: one file copied, and the same file listed by `ls -lh ~/backups/stirling-pdf/`.

If you do not: `Permission denied (publickey)` means you ran it on the server by mistake. The
`vps:` prefix only means something on your own machine.

Now prove the restore, because a backup you have never restored is a guess:

```bash
cd /srv/stirling-pdf
docker compose down
sudo rm -rf /srv/stirling-pdf/config
sudo tar -C /srv/stirling-pdf -xzf /srv/stirling-pdf/backups/stirling-pdf-$(date +%F).tar.gz
docker compose up -d
```

You should see: `Created` and `Started`, then after a minute or two a sign-in page at
https://<DOMAIN> that accepts the password you set in step 7.

If you do not: a sign-in page that rejects that password means the archive predates your
password change, and you are looking at the account as it was when the backup was taken. Take
another backup now that you have changed it. Those four commands are the whole disaster plan,
and you have now run them once.

## 9. Updating later

New versions are listed at https://github.com/Stirling-Tools/Stirling-PDF/releases. Take a
backup first, then edit the `image:` line in /srv/stirling-pdf/compose.yml to the new tag and
its digest.

```bash
cd /srv/stirling-pdf
docker compose pull
docker compose up -d
docker compose logs --tail 20 stirling-pdf
```

You should see: `Recreated`, then Spring startup lines and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Stirling-PDF
migrates the H2 file on the first boot after an upgrade, so wait for
`docker inspect --format '{{.State.Health.Status}}' stirling-pdf` to print `healthy` before you
call the update done.

## 10. What will probably go wrong

The first boot. I watched `docker ps` report the container as unhealthy for close to two
minutes and assumed the install had failed, when the JVM was still unpacking LibreOffice and
building its font cache. On a 2 GB box that start took longer than every other step here
combined. If step 7 reports `starting`, read `docker compose logs --tail 40 stirling-pdf`
before changing anything: a boot in progress prints Spring startup lines, a real failure prints
a stack trace or the OOM killer.

## 11. Out of scope

- Do not set `SECURITY_ENABLELOGIN=false`. This instance answers on a public hostname, and
  the unauthenticated mode is for a machine nobody else can reach.
- Do not configure OAuth2 or SAML sign-on. Both need an identity provider registered
  elsewhere, which is your decision to make later.
- Do not add a /usr/share/tessdata mount for extra OCR languages. English is bundled.
- Do not enable `METRICS_ENABLED` or install Prometheus. Nothing on this box scrapes it.
````

## docker-compose.yml

```yaml
# Stirling-PDF · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image and ports .... https://docs.stirlingpdf.com/Installation/Docker%20Install
#   login settings ..... https://docs.stirlingpdf.com/Configuration/System%20and%20Security/
#
# One container, no database process: the user table is an embedded H2 file under
# /configs, so that directory plus the .env is the whole install. The image runs
# as uid 1000, hence the ownership in step 2. Tag and digest are the 2.14.2
# release read from Docker Hub on 2026-08-05, for linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  stirling-pdf:
    image: stirlingtools/stirling-pdf:2.14.2@sha256:7ed4d9681d18e4fbc3aa6a63647c4b5c2bcc4b75841df7c05d7e3d2320f5c9a1
    container_name: stirling-pdf
    restart: unless-stopped
    env_file: /srv/stirling-pdf/.env
    volumes:
      # The H2 user database and the generated server certificate live here.
      # One mount, so one directory to copy. OCR language packs beyond the
      # bundled English set would need a second mount at /usr/share/tessdata.
      - /srv/stirling-pdf/config:/configs
    ports:
      # Loopback only. The Caddy that Prompt Zero installed on the host is the
      # only thing that can reach this port, and 8087 never enters the firewall.
      - "127.0.0.1:8087:8080"
```

## Caddyfile

```text
# Stirling-PDF · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://caddyserver.com/docs/automatic-https and
# https://docs.stirlingpdf.com/Installation/Docker%20Install
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. Caddy runs under systemd
# on the host. There is no Caddy container anywhere in this project.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8087 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. Caddy sets no request
	# body limit of its own, so SYSTEM_MAXFILESIZE in .env is the ceiling.
	reverse_proxy 127.0.0.1:8087
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Stirling-PDF · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=pdf.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.stirlingpdf.com/Installation/Docker%20Install
#   https://docs.stirlingpdf.com/Configuration/System%20and%20Security/
#   https://caddyserver.com/docs/automatic-https
#
# One secret is generated here, on this machine: the first-login credential for
# the admin account. It is written to /srv/stirling-pdf/.env with mode 600 and it
# is never printed to the terminal. Stirling-PDF forces a change on first login,
# so it is a bootstrap value with a short life.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/stirling-pdf}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_USER="${ADMIN_USER:-admin}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. pdf.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; this install wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; the image alone is several GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# The image defaults PUID and PGID to 1000 and drops to that user, so /configs is
# owned by 1000 rather than by you.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/config"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the one secret, on the server -------------------------------
#
# This value has never existed anywhere else: not in the prompt, not in a chat
# window, not in this repository. Read it later with
#   sudo grep SECURITY_INITIALLOGIN_PASSWORD /srv/stirling-pdf/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		DISABLE_ADDITIONAL_FEATURES=false
		SECURITY_ENABLELOGIN=true
		SECURITY_INITIALLOGIN_USERNAME=${ADMIN_USER}
		SECURITY_INITIALLOGIN_PASSWORD=$(openssl rand -base64 24)
		SYSTEM_DEFAULTLOCALE=en-GB
		SYSTEM_MAXFILESIZE=100
		SYSTEM_GOOGLEVISIBILITY=false
		METRICS_ENABLED=false
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-stirling-pdf"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8087 is not one of them -------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8087 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first boot is slow. This is a JVM plus LibreOffice, Calibre and Tesseract,
# and the image's own health check allows two minutes before it starts counting.

docker compose pull
docker compose up -d

echo "==> waiting for the container's own health check to go green (up to 5 minutes)"
for _ in $(seq 1 60); do
	state="$(docker inspect --format '{{.State.Health.Status}}' stirling-pdf 2>/dev/null || echo starting)"
	[ "$state" = "healthy" ] && break
	sleep 5
done
[ "${state:-}" = "healthy" ] || die "container health is ${state:-unknown}. Check: docker compose logs --tail 40 stirling-pdf"

# --- 7. Prove it works before claiming it does -------------------------------

echo "==> waiting for https://${DOMAIN_HOST}/ (Caddy is getting a certificate)"
for _ in $(seq 1 30); do
	code="$(curl -sSL -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 5
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/ answered ${code:-nothing}. Check: sudo journalctl -u caddy -n 30"

curl -sSL "https://${DOMAIN_HOST}/" | grep -qi 'stirling' \
	|| die "the page answered 200 but does not mention Stirling. Check: docker compose logs --tail 40 stirling-pdf"

# --- 8. The first backup, before day one ends --------------------------------
#
# Stopped, then copied. The H2 user database captured mid-write is not a backup.

docker compose stop
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/stirling-pdf-$(date +%Y%m%d-%H%M%S).tar.gz" config .env
docker compose start
ls -lh "$APP_DIR/backups/"

cat <<-DONE

	Stirling-PDF is running at https://${DOMAIN_HOST}/

	  1. Sign in as ${ADMIN_USER}. Read the one-time credential with
	     sudo grep SECURITY_INITIALLOGIN_PASSWORD $APP_DIR/.env
	     Stirling-PDF makes you choose a new one immediately. Put that new one
	     in your password manager; the value in .env stops mattering.
	  2. Nothing you upload is kept. Files are processed and returned, and the
	     temporary copy is discarded. This is a workshop, not a document store.
	  3. First backup written to $APP_DIR/backups. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/adobe-acrobat/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
