Can I self-host Adobe Acrobat?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Stirling-PDF. It takes one prompt, a 2048 MB VPS, and about 10 minutes. That is $24.99 a month you stop paying Adobe Acrobat — $299.88 a year on the Acrobat Standard plan.

  • adobe.com
  • Files & docs
  • prices checked 2026-08-05

Why people pay for Adobe Acrobat

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Acrobat is the reference implementation of the PDF specification, written by the company that wrote the specification, and that shows up in the boring places: a form that fills correctly on a phone, an OCR pass that gets a scanned invoice right the first time, and a signature a bank's compliance desk has seen before. You are paying for the file opening the same way on somebody else's machine.

Adobe Acrobat plans and list prices
PlanList priceWhat it buys
Acrobat ReaderfreeFree. Reads, prints and comments. It does not edit, combine, compress, OCR or redact, which is the boundary the paid tiers sell.
Acrobat Standardthe plan this page prices against$24.99/moMonth-to-month, one person. About $14.99/mo on an annual plan paid monthly, which locks you in for a year.
Acrobat Pro$29.99/moMonth-to-month, one person. About $19.99/mo on an annual plan paid monthly. Adds redaction, PDF comparison and the advanced form and accessibility tooling.

Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: low

Replaced by Stirling-PDF

One project, named before the prompt, so you know what you are about to install.

Sixty-odd PDF tools in one container, running on your machine, with nothing uploaded to anybody else's.

The only one here that covers the whole boring middle of Acrobat, splitting, merging, rotating, compressing, OCR and form flattening, from a single container with no database behind it. It is a toolbox rather than an editor: it will not let you retype a paragraph inside a page the way Acrobat Pro does, and the page says so before you install it.

The swap

You're paying

Adobe Acrobat

$24.99/mo · $299.88/yr

is replaced by

You'd run

Stirling-PDF

ONE COMMAND · ~10 min to running · 2048 MB RAM

Adobe Acrobat Acrobat Standard · vendor list price · checked 2026-08-05 · source · confidence: low

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it
Time budget
~10 minunder 10 minutes, through the first backup

The prompt

One prompt, assuming Prompt Zero is done. It installs Stirling-PDF — read it before you paste it, which is the whole reason it is on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

260 lines · 10,900 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

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.

agent-readable mirror: /self-host/adobe-acrobat.md

The files, if you'd rather do it yourself

The same install with no agent involved: three files, in the order you'd use them. The prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs.

compose.ymlthe services, pinned27 lines

authored from upstream docs, never pasted · 1,437 bytes

# 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"
Caddyfilethe hostname and TLS25 lines

authored from upstream docs, never pasted · 910 bytes

# 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.shthe same install, no agent150 lines

authored from upstream docs, never pasted · 6,337 bytes

#!/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

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Adobe Acrobat.

  • Nothing you send it is kept. Files are processed and handed back, and the working copy is discarded, so the only state on disk is the account database under /srv/stirling-pdf/config. That makes the backup trivial and it also means this is a workshop, not a document store: it will not replace the folder your invoices live in.
  • It is a toolbox, not an editor. Splitting, merging, rotating, compressing, converting, OCR, watermarks and form flattening are all here. Retyping a paragraph inside an existing page, the thing Acrobat Pro is bought for, is not.
  • The heaviest install in this catalogue. The image carries a JVM, LibreOffice, Calibre and Tesseract, it wants 2 GB of RAM available before you start, and the first boot takes a couple of minutes during which the container reports itself unhealthy.
  • The licence is MIT with carve-outs. The top-level LICENSE is MIT, but directories named proprietary, saas and engine carry their own terms, so the repository as a whole is not uniformly open source and GitHub reports no single licence for it. What this compose file runs is the published community image.
  • You own the login decision. This install turns login on and replaces the shipped default before the container starts, because the instance answers on a public hostname. If you later turn login off, anyone who finds the hostname can push documents through your server.

Where this came from

“Users will be forced to change their password on first login.”

  • Login is on by default in the Docker images, the shipped account is admin with the password stirling, and both halves can be replaced before first boot with SECURITY_INITIALLOGIN_USERNAME and SECURITY_INITIALLOGIN_PASSWORD. source
  • The image serves on container port 8080 and keeps the user database and the generated server certificate under /configs, which is the directory that has to be mounted to survive an upgrade. source
  • The image carries its own health check, which polls /api/v1/info/status and expects the response to contain UP, and allows a two-minute start period before counting failures. source
  • Caddy obtains and renews TLS certificates automatically for any public hostname named in the Caddyfile. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Adobe Acrobat?

    Not Adobe Acrobat itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Stirling-PDF. Sixty-odd PDF tools in one container, running on your machine, with nothing uploaded to anybody else's. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Adobe Acrobat?

    Stirling-PDF. Sixty-odd PDF tools in one container, running on your machine, with nothing uploaded to anybody else's. The only one here that covers the whole boring middle of Acrobat, splitting, merging, rotating, compressing, OCR and form flattening, from a single container with no database behind it. It is a toolbox rather than an editor: it will not let you retype a paragraph inside a page the way Acrobat Pro does, and the page says so before you install it. Stirling-PDF is MIT-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Adobe Acrobat?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Stirling-PDF itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Adobe Acrobat Acrobat Standard, $24.99/mo — $299.88 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Stirling-PDF install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

Content last checked 2026-08-05. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.