Can I self-host DeepL Pro?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called LibreTranslate. It takes one prompt, a 2048 MB VPS, and about 10 minutes. That is $8.74 a month you stop paying DeepL Pro — $104.88 a year on the Individual plan.

Why people pay for DeepL Pro

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.

DeepL sells one thing and sells it well: translations that read as though a person wrote them. That is not a feature list, it is the output itself, and it is why people with a free translator open in another tab still pay for this one. The subscription then adds what turns a demo into a working tool, which its own page summarises as translating text, Word docs and other docs securely: whole documents that come back looking like the file you uploaded, glossaries that hold your product names steady across a team, and an API your systems can call without anyone pasting anything.

DeepL Pro plans and list prices
PlanList priceWhat it buys
Individualthe plan this page prices against$8.74/moThe vendor page describes this tier as professional-grade translations for single users. Figure recorded is the annual-billed rate per month as reported by independent pricing trackers; month-to-month is roughly a fifth higher.
Team$28.74/mo per seatDescribed by the vendor as increased translation productivity and control for teams. Annual-billed rate per user per month, per the same trackers.
Business$57.49/mo per seatDescribed by the vendor as multilingual communication management for businesses. Annual-billed rate per user per month, per the same trackers.
Enterprisequote onlyQuote only. The vendor page sends this tier to contact sales and describes it as scalability, control and security for organizations.
DeveloperfreeThe free API tier, described on the vendor page as testing out the API for free. It carries a character allowance we did not record, because the page renders that figure in JavaScript and we do not publish numbers we could not read.
Growth$26/mo meteredThe paid API tier, described by the vendor as advanced capabilities and higher usage for growing teams. Trackers report about $26 a month on annual billing with an included yearly character volume and a per-million-character rate above it, which is the billing shape self-hosting removes entirely.
Enterprise APIquote onlyQuote only. Described by the vendor as being for large-scale, long-term API projects.

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

Replaced by LibreTranslate

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

Machine translation on your own box, with no per-character meter and nothing sent to a vendor, at Argos model quality rather than DeepL's.

The only option here with the shape of the thing you are cancelling: a translation API and a web text box you point your own tools at, running models on hardware you own. Be clear-eyed about what changes. The engine is Argos Translate on OpenNMT models, and on most pairs the output sits a tier below DeepL's, further below on languages with less training data behind them, and further still on pairs that have to pivot through English. What you get in exchange is that nothing you translate leaves the building, no meter runs per character, and the API answers with the network unplugged. For gist, internal documents and anything a person edits afterwards, that is a fair trade. For a contract or a customer-facing page, it is not, and this is the wrong page for that job.

The swap

You're paying

DeepL Pro

$8.74/mo · $104.88/yr

is replaced by

You'd run

LibreTranslate

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

DeepL Pro Individual · vendor list price · checked 2026-08-07 · source · confidence: medium

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 on the cloud path, and the local path needs none
Time budget
~10 minunder 10 minutes, through the first backup

The prompt

Two paths to the same LibreTranslate: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

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

Where it runs

311 lines · 14,955 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 LibreTranslate 1.9.6 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, and every script or app they point here is
configured with `https://<DOMAIN>`.

LibreTranslate loads neural translation models into memory, one copy per worker. It needs 2048
MB of RAM available and 10 GB free on /srv, and publishes 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. Do
not install and hope: the models in step 4 are 2.1 GB before the image, and the OOM killer
arrives in the middle of a translation rather than at start-up. If `dig +short` prints nothing,
print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/libretranslate /srv/libretranslate/backups
sudo install -d -m 750 -o 1032 -g 1032 /srv/libretranslate/db
ls -la /srv/libretranslate
```

Assert: `ls -la` shows `backups` owned by the login user and `db` owned by `1032`. That number
is not a mistake and not a user on this host: the image creates its own account with uid 1032
and runs as it, so the directory holding its key database has to belong to that uid. There is
no `data` directory and there will not be one: nothing the user translates is stored.

## 3. Secrets

One secret: the API key every translation request has to carry. Generate it here, print it
nowhere, keep it out of your summary and out of any log line. It is the whole access control
here, because step 4 sets `LT_UNDER_ATTACK` and the application refuses a keyless request
itself rather than leaning on a password box API clients could not answer.

```bash
umask 077
openssl rand -hex 24 | tr -d '\n' > /srv/libretranslate/api-key
chmod 600 /srv/libretranslate/api-key
umask 022
ls -l /srv/libretranslate/api-key
```

Assert: the file exists with mode `-rw-------`. Hex rather than base64, because this value gets
typed into settings boxes on other machines. The trailing newline is stripped on purpose so the
file can be handed to curl and to the container verbatim. Do not run the command that lists
keys at any point: it prints their values straight to the terminal, which is the one thing this
step exists to avoid.

## 4. compose.yml

```bash
cat > /srv/libretranslate/compose.yml <<'EOF'
# LibreTranslate · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   installation ....... https://docs.libretranslate.com/guides/installation/
#   quickstart ......... https://docs.libretranslate.com/
#   argument defaults .. https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/libretranslate/default_values.py
#   image build ........ https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/docker/Dockerfile
#   container start .... https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/scripts/entrypoint.sh
#
# One service and no database process. Every setting below is an LT_ variable:
# upstream reads each argument from LT_ plus the argument name in upper snake
# case, and default_values.py is the list of them.
#
# Two mounts, doing different jobs. db holds one SQLite file, the API key
# database, and it is the only thing here worth backing up. The model cache is a
# named volume because the image creates its own user with uid 1032 and chowns
# /home/libretranslate to it: a fresh named volume inherits that ownership and a
# home-directory bind mount cannot. Upstream's run.sh mounts both the same way.
#
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The -cuda tag is a separate amd64-only image this file does
# not use: the CPU image is upstream's ordinary path.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  libretranslate:
    image: libretranslate/libretranslate:v1.9.6@sha256:1de2d7056bb8ad607a412f4563d9abe324ff632b43b5be9428bcc8e213aebb32
    container_name: libretranslate
    restart: unless-stopped
    environment:
      # Eleven languages, each paired with English in both directions and
      # pivoted through English for every other combination. That is 22 model
      # packages, about 2.1 GB, downloaded during the first start. Deleting this
      # line downloads all 100 packages in the index instead, about 8.4 GB.
      LT_LOAD_ONLY: "en,es,fr,de,it,pt,nl,pl,ru,zh,ja"
      # The key database, and the mode that makes a key mandatory on every
      # translate, detect and file route. Without both of these, whatever can
      # reach this port can spend this machine's CPU.
      LT_API_KEYS: "true"
      LT_UNDER_ATTACK: "true"
      # entrypoint.sh hands this to gunicorn as --workers. Each worker loads its
      # own copy of a model the first time a pair is used, so this number
      # multiplies memory. Upstream's default is 4.
      LT_THREADS: "2"
      # One request cannot occupy a worker forever. 20000 characters is a long
      # document; upstream's default is no ceiling at all.
      LT_CHAR_LIMIT: "20000"
    volumes:
      - /srv/libretranslate/db:/app/db
      - libretranslate-models:/home/libretranslate/.local
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8154.
      - "127.0.0.1:8154:5000"
    healthcheck:
      # Upstream's own script. It exits 0 while /tmp/booting.flag exists, so the
      # container does not report unhealthy during the first model download.
      test: ["CMD-SHELL", "./venv/bin/python scripts/healthcheck.py"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 900s

volumes:
  libretranslate-models:
EOF
cd /srv/libretranslate && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. There is no `env_file` line: the secret from step 3 goes into
the application's own key database in step 7, so the service reads it from SQLite rather than
from a variable `docker inspect` would show.

## 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 here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-libretranslate
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LibreTranslate · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.libretranslate.com/guides/installation/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. The application
# runs with LT_API_KEYS and LT_UNDER_ATTACK set, so it refuses a translation
# request carrying no key of its own. This block terminates TLS in front of that
# and adds no second credential: a browser password box would stop the API
# clients this install exists to serve.

<DOMAIN> {
	# The web page is HTML and the API answers JSON, and both compress well.
	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
	}

	# 8154 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8154
}
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-libretranslate, reload, and report what it objected to. Caddy
requests the certificate on the first request to the hostname and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's. 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 redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8154 stays closed because it is bound to 127.0.0.1, and opening it would put
the service on the open internet with the proxy skipped. Assert: `ufw status verbose`
prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8154.

## 7. Start and verify

The first start downloads the 22 model packages named by `LT_LOAD_ONLY`, about 2.1 GB, before
anything answers. The loop below is patient for that reason, and
`docker compose logs -f libretranslate` prints a `Downloading` line per package.

```bash
cd /srv/libretranslate
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/languages | grep -o '"code": *"es"' | head -1
docker compose exec -T libretranslate sh -c 'ltmanage keys add 120 --key "$(cat)"' < /srv/libretranslate/api-key > /dev/null
curl -sS -w '\n%{http_code}\n' --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" https://<DOMAIN>/translate
curl -sS --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" --data-urlencode "api_key@/srv/libretranslate/api-key" https://<DOMAIN>/translate
curl -sS https://<DOMAIN>/ | grep -o 'Translation API' | head -1
```

Assert, all six, and print what you received for each. The loop ends printing `200`. The health
response carries `"status"` reading `ok`. The languages grep prints `"code":"es"`. The keyless
call prints `400` and a body containing `Please contact the server operator to get an API key`,
the security assert here: an open translation endpoint on a public name is free compute for
whoever finds it. The keyed call returns JSON containing `"translatedText"` with Spanish in it.
The last command prints `Translation API`, the heading on the first screen at https://<DOMAIN>.
If any of the six misses, stop, run `docker compose logs --tail 40 libretranslate`, and name
the likely earlier step: a `502` from the loop means the models are still downloading,
`Permission denied` near `api_keys` means step 2 created `db` with the wrong owner, and a `403`
with `Invalid API key` means the registration line ran before the container was up. A running
container is not success.

The key travels on standard input in that registration line, so it is in no command line and no
process list, and `> /dev/null` discards the copy the tool echoes. `120` is its per-minute
request limit.

STOP: tell the user to read their key with `cat /srv/libretranslate/api-key`, put it in their
password manager, then open https://<DOMAIN>, click the key icon in the top bar, paste it, and
translate one sentence. Do not continue until they confirm. The banner about bot abuse on that
page is upstream's wording for the mode this install runs in, not a fault.

## 8. First backup and restore

One archive: the key, the key database, the compose file and the Caddy block. The models are
not in it on purpose: 2.1 GB of cache that downloads itself again, and a backup that takes an
hour to copy is one nobody runs twice.

```bash
cd /srv/libretranslate
sudo tar -czf /srv/libretranslate/backups/libretranslate-$(date +%F).tar.gz -C /srv/libretranslate compose.yml api-key db -C /etc/caddy Caddyfile
ls -lh /srv/libretranslate/backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped: the key
database is written only when a key is added or removed. The archive carries the key twice, so
treat it as the secret it is. A backup on the same disk is not a backup, so run this from the
user's machine:

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

To restore on a fresh box: recreate the layout from step 2, untar the archive into
/srv/libretranslate as root so `db` keeps its uid 1032 owner, put `Caddyfile` back in
/etc/caddy with `<DOMAIN>` substituted, `sudo systemctl reload caddy`, then
`docker compose up -d` and re-run step 7's asserts. The models download again and take as long
as before. Tell the user the honest version: losing this archive costs the key and an hour, not
their documents, which were never stored here.

## 9. Updating later

New versions are listed at https://github.com/LibreTranslate/LibreTranslate/releases. Take the
backup first, then edit the image line in /srv/libretranslate/compose.yml to the new tag and
its digest:

```bash
cd /srv/libretranslate
docker compose pull
docker compose up -d
docker compose logs --tail 30 libretranslate
```

Watch that log until the server reports it is listening, then re-run step 7's asserts before
calling the update done. The models are versioned separately from the software and this file
does not pin them: they resolve against a package index at download time, so a rebuilt cache
can hold a newer model than the one it replaced.

## 10. What will probably go wrong

The first start looks like a broken install for several minutes. `docker compose up -d`
returned in about a second, `docker compose ps` said the container was up and healthy, and
every request to https://<DOMAIN> came back `502`. I re-read the Caddy block twice before
running `docker compose logs -f libretranslate` and watching a `Downloading translate-en_es`
line crawl past. Nothing serves until the last of the 22 packages is installed, and the health
check upstream ships reports success throughout because the container is booting on purpose. If
step 7's loop prints `502`, read the log first.

## 11. Out of scope

- Do not switch to the `-cuda` image tag. It is a separate amd64-only image needing an NVIDIA
  card and the container toolkit; the CPU image is what upstream builds for both architectures.
- Do not remove `LT_API_KEYS` or `LT_UNDER_ATTACK`. That pair is the entire access control on a
  public hostname, and there is no second lock behind it.
- Do not delete `LT_LOAD_ONLY` to get every language. That downloads all 100 packages in the
  index, about 8.4 GB, on a disk this prompt sized for 10.
- Do not set `LT_SUGGESTIONS` and do not configure SMTP. Suggestions open a write path for
  anyone holding a key, and the service sends no mail at all.
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 LibreTranslate 1.9.6 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.

One thing to know before step 1, because it decides how long you wait. LibreTranslate ships no
language models inside the image. The first start downloads them, about 2.1 GB, and nothing
answers until that finishes.

## 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 on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
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. Under 2048 MB of memory
is the one number here worth respecting: each worker loads its own copy of a translation model,
and the OOM killer arrives in the middle of a translation rather than at start-up, so the
failure looks random when it is not.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/libretranslate /srv/libretranslate/backups
sudo install -d -m 750 -o 1032 -g 1032 /srv/libretranslate/db
ls -la /srv/libretranslate
```

You should see: `backups` owned by you, and `db` owned by `1032` rather than by a name.

If you do not: `1032` is correct and it is not a user on this machine. The image creates its
own account with uid 1032 and runs as that account, so the directory it writes its key database
into has to belong to that uid. If you chown `db` to yourself, the container starts and then
cannot write, and the error you see later says `Permission denied` about a path you have never
heard of. There is no `data` directory here on purpose: nothing you translate is stored.

## 3. Secrets

One secret, the API key that every translation request will carry. It is generated here, on the
server, and goes straight into a file only you can read.

```bash
umask 077
openssl rand -hex 24 | tr -d '\n' > /srv/libretranslate/api-key
chmod 600 /srv/libretranslate/api-key
umask 022
ls -l /srv/libretranslate/api-key
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run
`chmod 600 /srv/libretranslate/api-key` and carry on. The trailing newline is stripped on
purpose, because step 7 hands this file straight to curl and to the container and a stray
newline would be part of the key.

Do not paste that file, the key, or any command output containing it into this chat window. And
do not run the command that lists keys while a chat window is open: it prints their values
straight to your terminal, which is exactly what step 3 exists to avoid. Read your own key when
you need it with `cat /srv/libretranslate/api-key`.

## 4. compose.yml

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

```bash
cat > /srv/libretranslate/compose.yml <<'EOF'
# LibreTranslate · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   installation ....... https://docs.libretranslate.com/guides/installation/
#   quickstart ......... https://docs.libretranslate.com/
#   argument defaults .. https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/libretranslate/default_values.py
#   image build ........ https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/docker/Dockerfile
#   container start .... https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/scripts/entrypoint.sh
#
# One service and no database process. Every setting below is an LT_ variable:
# upstream reads each argument from LT_ plus the argument name in upper snake
# case, and default_values.py is the list of them.
#
# Two mounts, doing different jobs. db holds one SQLite file, the API key
# database, and it is the only thing here worth backing up. The model cache is a
# named volume because the image creates its own user with uid 1032 and chowns
# /home/libretranslate to it: a fresh named volume inherits that ownership and a
# home-directory bind mount cannot. Upstream's run.sh mounts both the same way.
#
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The -cuda tag is a separate amd64-only image this file does
# not use: the CPU image is upstream's ordinary path.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  libretranslate:
    image: libretranslate/libretranslate:v1.9.6@sha256:1de2d7056bb8ad607a412f4563d9abe324ff632b43b5be9428bcc8e213aebb32
    container_name: libretranslate
    restart: unless-stopped
    environment:
      # Eleven languages, each paired with English in both directions and
      # pivoted through English for every other combination. That is 22 model
      # packages, about 2.1 GB, downloaded during the first start. Deleting this
      # line downloads all 100 packages in the index instead, about 8.4 GB.
      LT_LOAD_ONLY: "en,es,fr,de,it,pt,nl,pl,ru,zh,ja"
      # The key database, and the mode that makes a key mandatory on every
      # translate, detect and file route. Without both of these, whatever can
      # reach this port can spend this machine's CPU.
      LT_API_KEYS: "true"
      LT_UNDER_ATTACK: "true"
      # entrypoint.sh hands this to gunicorn as --workers. Each worker loads its
      # own copy of a model the first time a pair is used, so this number
      # multiplies memory. Upstream's default is 4.
      LT_THREADS: "2"
      # One request cannot occupy a worker forever. 20000 characters is a long
      # document; upstream's default is no ceiling at all.
      LT_CHAR_LIMIT: "20000"
    volumes:
      - /srv/libretranslate/db:/app/db
      - libretranslate-models:/home/libretranslate/.local
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8154.
      - "127.0.0.1:8154:5000"
    healthcheck:
      # Upstream's own script. It exits 0 while /tmp/booting.flag exists, so the
      # container does not report unhealthy during the first model download.
      test: ["CMD-SHELL", "./venv/bin/python scripts/healthcheck.py"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 900s

volumes:
  libretranslate-models:
EOF
cd /srv/libretranslate && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `services must be a mapping` means the indentation was lost between the page and
your terminal. Run `rm /srv/libretranslate/compose.yml` and paste again in one go. There is no
`env_file` line and that is deliberate: the key from step 3 never enters the container's
environment, because step 7 puts it in the application's own database instead, where
`docker inspect` cannot read it back out.

## 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-libretranslate
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LibreTranslate · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.libretranslate.com/guides/installation/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. The application
# runs with LT_API_KEYS and LT_UNDER_ATTACK set, so it refuses a translation
# request carrying no key of its own. This block terminates TLS in front of that
# and adds no second credential: a browser password box would stop the API
# clients this install exists to serve.

<DOMAIN> {
	# The web page is HTML and the API answers JSON, and both compress well.
	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
	}

	# 8154 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8154
}
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-libretranslate /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and
not the other. Caddy requests the certificate on the first request to the 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 `8154`.

If you do not: delete anything for `8154` with `sudo ufw delete allow 8154`. It is bound to
127.0.0.1 by the compose file, so a firewall rule for it would either do nothing or open a
translation service to the internet with the proxy skipped. 80/tcp redirects to HTTPS and
answers the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy
offers by default. `Status: inactive` is a different problem: Prompt Zero left this firewall
enabled, so something has turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

The first start downloads the 22 model packages named by `LT_LOAD_ONLY` before anything
answers. Run the first three lines, then leave the loop alone.

```bash
cd /srv/libretranslate
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS https://<DOMAIN>/health
curl -sS https://<DOMAIN>/languages | grep -o '"code": *"es"' | head -1
```

You should see: several minutes of `502` from the loop, then `200`, then a small JSON object
whose `status` field reads `ok`, then one line printing `"code":"es"`, which means the Spanish
models finished installing.

If you do not: open a second terminal and run
`ssh vps 'cd /srv/libretranslate && docker compose logs -f libretranslate'`. A `Downloading`
line per package means it is working and you are early. A container restarting in a loop with
no `Downloading` lines usually means step 2 gave `db` the wrong owner. If the loop runs out at
60 without a `200`, that is fifteen minutes, which is longer than this should take on any
normal connection: read the log before touching the Caddy block.

Now register the key from step 3 with the running service, and prove the two halves of the
access rule.

```bash
docker compose exec -T libretranslate sh -c 'ltmanage keys add 120 --key "$(cat)"' < /srv/libretranslate/api-key > /dev/null
curl -sS -w '\n%{http_code}\n' --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" https://<DOMAIN>/translate
curl -sS --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" --data-urlencode "api_key@/srv/libretranslate/api-key" https://<DOMAIN>/translate
curl -sS https://<DOMAIN>/ | grep -o 'Translation API' | head -1
```

You should see: nothing from the first line, then a body containing
`Please contact the server operator to get an API key` followed by `400`, then JSON containing
`"translatedText"` with Spanish in it, then `Translation API`.

If you do not: the `400` is the one worth understanding. It means the service is up and
refusing a call that carries no key, which is the whole security posture of this install, so
seeing it is good news. `403` with `Invalid API key` on the third line means the key in the
file and the key in the database do not match, which happens if you re-ran step 3 after
registering: run the registration line again. The first line prints nothing on purpose, because
the tool would otherwise echo your key into this terminal.

The first screen at https://<DOMAIN> is a text box under the heading `Translation API`. It also
carries a banner about bot abuse and API keys. That is upstream's own wording for the mode this
install runs in, not a fault. Click the key icon in the top bar, paste your key once, and the
box starts working. A running container is not success; the four lines above are.

## 8. First backup and restore

One archive: the key, the key database, the compose file and the Caddy block. The models are
not in it, on purpose. They are 2.1 GB of cache that downloads itself again.

```bash
cd /srv/libretranslate
sudo tar -czf /srv/libretranslate/backups/libretranslate-$(date +%F).tar.gz -C /srv/libretranslate compose.yml api-key db -C /etc/caddy Caddyfile
ls -lh /srv/libretranslate/backups/
```

You should see: one file, a few kilobytes on a fresh install. Nothing goes offline.

If you do not: `tar: db: Cannot open: Permission denied` means you dropped the `sudo`. The `db`
directory belongs to uid 1032 and your own account cannot read it.

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

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

You should see: one file copied, and it listed by `ls -lh ~/backups/libretranslate/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:`
prefix only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is a test key:

```bash
cd /srv/libretranslate
docker compose down
sudo rm -rf /srv/libretranslate/db
sudo tar -xzf /srv/libretranslate/backups/libretranslate-$(date +%F).tar.gz -C /srv/libretranslate db
docker compose up -d
sleep 60
curl -sS --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" --data-urlencode "api_key@/srv/libretranslate/api-key" https://<DOMAIN>/translate
```

You should see: JSON containing `"translatedText"` again, which means the key database came
back out of the archive and the service accepted the same key.

If you do not: `403` with `Invalid API key` means the untar did not restore `db`, so check
`ls -la /srv/libretranslate/db` for `api_keys.db` owned by `1032`. The models are untouched by
this, because they live in a Docker volume the archive never held, which is why this restore
takes a minute rather than the first start's several. Treat the archive as the secret it is: it
carries your key twice.

## 9. Updating later

New versions are listed at https://github.com/LibreTranslate/LibreTranslate/releases. Take the
backup first, then edit the `image:` line in /srv/libretranslate/compose.yml to the new tag and
its digest.

```bash
cd /srv/libretranslate
docker compose pull
docker compose up -d
docker compose logs --tail 30 libretranslate
```

You should see: the server starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
checks from step 7 before you call the update done. One thing the pin does not cover: the
models are versioned separately from the software and this file does not pin them, so a cache
rebuilt after an update can hold a newer model than the one it replaced, and a translation can
change without the software changing.

## 10. What will probably go wrong

The first start looks like a broken install for several minutes. `docker compose up -d`
returned in about a second, `docker compose ps` said the container was up and healthy, and
every request to https://<DOMAIN> came back `502`. I re-read the Caddy block twice before
running `docker compose logs -f libretranslate` and watching a `Downloading translate-en_es`
line crawl past. Nothing serves until the last of the 22 packages is installed, and the health
check upstream ships reports success throughout because the container is booting on purpose. If
step 7's loop prints `502`, read the log first.

## 11. Out of scope

- Do not switch to the `-cuda` image tag. It is a separate amd64-only image needing an NVIDIA
  card and the container toolkit; the CPU image is what upstream builds for both architectures.
- Do not remove `LT_API_KEYS` or `LT_UNDER_ATTACK`. That pair is the entire access control on a
  public hostname, and there is no second lock behind it.
- Do not delete `LT_LOAD_ONLY` to get every language. That downloads all 100 packages in the
  index, about 8.4 GB, on a disk this guide sized for 10.
- Do not set `LT_SUGGESTIONS` and do not configure SMTP. Suggestions open a write path for
  anyone holding a key, and the service sends no mail at all.

295 lines · 14,949 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  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 this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install LibreTranslate 1.9.6 under ~/selfhost/libretranslate, answering at
http://localhost:8154, so documents and messages are translated by models on this disk.

## 1. Preflight

Say this to the user before anything installs. The translation they are about to get happens on
this computer and nowhere else: their phone and second laptop cannot reach
http://localhost:8154, so anything typed there goes elsewhere to be translated. In exchange,
not one sentence they paste here leaves this machine, which is what a translation subscription
cannot offer at any price.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. LibreTranslate loads neural translation
models into memory, one copy per worker, and needs 2048 MB of RAM available and 10 GB free on
the home disk. The image publishes amd64 and arm64, so an Apple Silicon Mac is covered. On
macOS and Windows the memory figure is the host's, and Docker Desktop's virtual machine takes
its allocation out of it. If available RAM is under 2048 MB or free disk is under 10 GB, print
both numbers and stop. Do not install and hope.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/libretranslate/backups
ls -la ~/selfhost/libretranslate
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder and there will
not be one: nothing the user translates is stored. The only thing this install writes is the
model cache, kept in a volume Docker manages, so no ownership fix is needed on any of the three
systems.

## 4. Secrets

None, and that is a real answer rather than a skipped step. No account to create, no password
to set, no key to mint. The server path generates an API key and turns on the mode that demands
one, because anybody can reach a public hostname and translation is expensive CPU to give away.
Here the boundary is the loopback binding, which step 6 explains. Generate nothing and move on.

## 5. compose.yml

```bash
cat > ~/selfhost/libretranslate/compose.yml <<'EOF'
# LibreTranslate · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ....... https://docs.libretranslate.com/guides/installation/
#   quickstart ......... https://docs.libretranslate.com/
#   argument defaults .. https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/libretranslate/default_values.py
#   image build ........ https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/docker/Dockerfile
#   container start .... https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/scripts/entrypoint.sh
#
# One service on the computer you are sitting at. No key database and no key
# requirement: the server path sets LT_API_KEYS and LT_UNDER_ATTACK because a
# public hostname needs a door, and here the loopback binding is the door.
#
# One mount, and it is a named volume rather than a folder you can open: the
# image creates its own user with uid 1032 and chowns /home/libretranslate to
# it, which a fresh named volume inherits and a home-directory bind mount
# cannot. Nothing is hidden from you by that. The model cache is all this
# install writes, because text arrives, is translated, is answered, and is
# dropped. Upstream's run.sh mounts the same path.
#
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The -cuda tag is a separate amd64-only image this file does
# not use: the CPU image is upstream's ordinary path.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  libretranslate:
    image: libretranslate/libretranslate:v1.9.6@sha256:1de2d7056bb8ad607a412f4563d9abe324ff632b43b5be9428bcc8e213aebb32
    container_name: libretranslate
    restart: unless-stopped
    environment:
      # Eleven languages, each paired with English in both directions and
      # pivoted through English for every other combination. That is 22 model
      # packages, about 2.1 GB, downloaded during the first start. Deleting this
      # line downloads all 100 packages in the index instead, about 8.4 GB.
      LT_LOAD_ONLY: "en,es,fr,de,it,pt,nl,pl,ru,zh,ja"
      # entrypoint.sh hands this to gunicorn as --workers. Each worker loads its
      # own copy of a model on first use, so this number multiplies memory and
      # Docker Desktop's virtual machine has a ceiling. Upstream's default is 4.
      LT_THREADS: "2"
      # One request cannot occupy a worker forever. 20000 characters is a long
      # document; upstream's default is no ceiling at all.
      LT_CHAR_LIMIT: "20000"
    volumes:
      - libretranslate-models:/home/libretranslate/.local
    ports:
      # Loopback only: no other device on the wifi reaches 8154, and nothing on
      # the internet does.
      - "127.0.0.1:8154:5000"
    healthcheck:
      # Upstream's own script. It exits 0 while /tmp/booting.flag exists, so the
      # container does not report unhealthy during the first model download.
      test: ["CMD-SHELL", "./venv/bin/python scripts/healthcheck.py"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 900s

volumes:
  libretranslate-models:
EOF
cd ~/selfhost/libretranslate && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule, and no API key. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
- No key. The server path demands one because anybody can reach a public hostname, which is
  exactly why it is unnecessary here.

8154 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop
on the same wifi, nor anyone on the internet. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/libretranslate/compose.yml
```

Assert: that prints `1`, the single published port, `- "127.0.0.1:8154:5000"`. Nothing else in
the file publishes anything.

## 7. Start and verify

The first start downloads the 22 model packages named by `LT_LOAD_ONLY`, about 2.1 GB, before
anything answers. The loop below is patient for that reason, and
`docker compose logs -f libretranslate` prints a `Downloading` line per package.

```bash
cd ~/selfhost/libretranslate
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8154/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 15; done
curl -sS http://localhost:8154/health
curl -sS http://localhost:8154/languages | grep -o '"code": *"es"' | head -1
curl -sS --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" http://localhost:8154/translate
curl -sS http://localhost:8154/ | grep -o 'Translation API' | head -1
```

Assert all five, and print what you received for each: the loop ends on `200`; the health
response carries `"status"` reading `ok`; the languages grep prints `"code":"es"`; the
translate call returns JSON containing `"translatedText"` with Spanish in it; the last command
prints `Translation API`, the heading on the first screen at http://localhost:8154. If any of
the five misses, stop, run `docker compose logs --tail 40 libretranslate`, and name the likely
cause: a connection refused while the loop runs means the models are still downloading, and a
container that keeps restarting near the RAM floor is step 1 saying it wanted more. If
`port is already allocated` came back, find what holds 8154 (`lsof -nP -iTCP:8154
-sTCP:LISTEN`, `ss -ltnp | grep 8154` on Linux, `netstat -ano | findstr :8154` on Windows) and
stop until the user frees it. A running container is not success.

STOP: tell the user to open http://localhost:8154, paste a sentence, pick a target language,
and confirm a translation appears. Do not continue until they confirm. That page is the whole
product, and the same JSON API sits underneath it for any script on this computer to post to
at http://localhost:8154/translate.

## 8. First backup and restore

The shortest backup on this site, because there is no user data to lose. The archive holds the
one file that rebuilds the service:

```bash
cd ~/selfhost/libretranslate
tar -C ~/selfhost/libretranslate -czf ~/selfhost/libretranslate/backups/libretranslate-$(date +%F).tar.gz compose.yml
ls -lh ~/selfhost/libretranslate/backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped: the models are
a cache and no translation is kept.

That archive sits on the same disk as the install, which is not a backup, and on a laptop the
disk and the machine fail together. Ask the user for a destination that leaves this computer, a
folder their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`; confirm the destination exists before
copying. Assert: the user confirms the filename is listed there.

To restore on any machine: untar the archive into ~/selfhost/libretranslate and run
`docker compose up -d`. The models download again and take as long as before. Tell the user the
honest version of what this backup is worth: ten minutes of setup and no documents at all,
because their documents were never stored here.

## 9. Updating later

New versions are listed at https://github.com/LibreTranslate/LibreTranslate/releases. Take the
backup first, then edit the image line in ~/selfhost/libretranslate/compose.yml to the new tag
and digest:

```bash
cd ~/selfhost/libretranslate
docker compose pull
docker compose up -d
docker compose logs --tail 30 libretranslate
```

Watch that log until the server reports it is listening, then re-run step 7's asserts before
calling the update done. The models are versioned separately from the software and this file
does not pin them: they resolve against a package index at download time, so a rebuilt cache
can hold a newer model than before.

## 10. What will probably go wrong

I rebooted this machine, opened http://localhost:8154 out of habit, and got a connection error
that read like a lost install. It was not: Docker Desktop had not started with the session, so
nothing was listening on 8154, and `restart: unless-stopped` acts only once the Docker daemon
is up. The models were exactly where I left them. Turn on Docker Desktop's start-at-login
setting, then after a reboot run `cd ~/selfhost/libretranslate && docker compose up -d` before
concluding anything is broken.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8154 to 0.0.0.0 so a phone on the wifi can reach it. With no key required, that
  hands an unauthenticated translation endpoint to every network the user joins.
- Do not switch to the `-cuda` image tag. It is a separate amd64-only image needing an NVIDIA
  card and the container toolkit; the CPU image is what upstream builds for both architectures.
- Do not delete `LT_LOAD_ONLY` to get every language. That downloads all 100 packages in the
  index, about 8.4 GB, on a disk this prompt sized for 10.
compose.local.ymlthe services, pinned · local layout60 lines

authored from upstream docs, never pasted · 3,180 bytes

# LibreTranslate · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installation ....... https://docs.libretranslate.com/guides/installation/
#   quickstart ......... https://docs.libretranslate.com/
#   argument defaults .. https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/libretranslate/default_values.py
#   image build ........ https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/docker/Dockerfile
#   container start .... https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/scripts/entrypoint.sh
#
# One service on the computer you are sitting at. No key database and no key
# requirement: the server path sets LT_API_KEYS and LT_UNDER_ATTACK because a
# public hostname needs a door, and here the loopback binding is the door.
#
# One mount, and it is a named volume rather than a folder you can open: the
# image creates its own user with uid 1032 and chowns /home/libretranslate to
# it, which a fresh named volume inherits and a home-directory bind mount
# cannot. Nothing is hidden from you by that. The model cache is all this
# install writes, because text arrives, is translated, is answered, and is
# dropped. Upstream's run.sh mounts the same path.
#
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The -cuda tag is a separate amd64-only image this file does
# not use: the CPU image is upstream's ordinary path.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  libretranslate:
    image: libretranslate/libretranslate:v1.9.6@sha256:1de2d7056bb8ad607a412f4563d9abe324ff632b43b5be9428bcc8e213aebb32
    container_name: libretranslate
    restart: unless-stopped
    environment:
      # Eleven languages, each paired with English in both directions and
      # pivoted through English for every other combination. That is 22 model
      # packages, about 2.1 GB, downloaded during the first start. Deleting this
      # line downloads all 100 packages in the index instead, about 8.4 GB.
      LT_LOAD_ONLY: "en,es,fr,de,it,pt,nl,pl,ru,zh,ja"
      # entrypoint.sh hands this to gunicorn as --workers. Each worker loads its
      # own copy of a model on first use, so this number multiplies memory and
      # Docker Desktop's virtual machine has a ceiling. Upstream's default is 4.
      LT_THREADS: "2"
      # One request cannot occupy a worker forever. 20000 characters is a long
      # document; upstream's default is no ceiling at all.
      LT_CHAR_LIMIT: "20000"
    volumes:
      - libretranslate-models:/home/libretranslate/.local
    ports:
      # Loopback only: no other device on the wifi reaches 8154, and nothing on
      # the internet does.
      - "127.0.0.1:8154:5000"
    healthcheck:
      # Upstream's own script. It exits 0 while /tmp/booting.flag exists, so the
      # container does not report unhealthy during the first model download.
      test: ["CMD-SHELL", "./venv/bin/python scripts/healthcheck.py"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 900s

volumes:
  libretranslate-models:

agent-readable mirror: /self-host/deepl-pro.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned64 lines

authored from upstream docs, never pasted · 3,343 bytes

# LibreTranslate · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
#   installation ....... https://docs.libretranslate.com/guides/installation/
#   quickstart ......... https://docs.libretranslate.com/
#   argument defaults .. https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/libretranslate/default_values.py
#   image build ........ https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/docker/Dockerfile
#   container start .... https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/scripts/entrypoint.sh
#
# One service and no database process. Every setting below is an LT_ variable:
# upstream reads each argument from LT_ plus the argument name in upper snake
# case, and default_values.py is the list of them.
#
# Two mounts, doing different jobs. db holds one SQLite file, the API key
# database, and it is the only thing here worth backing up. The model cache is a
# named volume because the image creates its own user with uid 1032 and chowns
# /home/libretranslate to it: a fresh named volume inherits that ownership and a
# home-directory bind mount cannot. Upstream's run.sh mounts both the same way.
#
# Tag and digest were read from Docker Hub on 2026-08-07; the image publishes
# amd64 and arm64. The -cuda tag is a separate amd64-only image this file does
# not use: the CPU image is upstream's ordinary path.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  libretranslate:
    image: libretranslate/libretranslate:v1.9.6@sha256:1de2d7056bb8ad607a412f4563d9abe324ff632b43b5be9428bcc8e213aebb32
    container_name: libretranslate
    restart: unless-stopped
    environment:
      # Eleven languages, each paired with English in both directions and
      # pivoted through English for every other combination. That is 22 model
      # packages, about 2.1 GB, downloaded during the first start. Deleting this
      # line downloads all 100 packages in the index instead, about 8.4 GB.
      LT_LOAD_ONLY: "en,es,fr,de,it,pt,nl,pl,ru,zh,ja"
      # The key database, and the mode that makes a key mandatory on every
      # translate, detect and file route. Without both of these, whatever can
      # reach this port can spend this machine's CPU.
      LT_API_KEYS: "true"
      LT_UNDER_ATTACK: "true"
      # entrypoint.sh hands this to gunicorn as --workers. Each worker loads its
      # own copy of a model the first time a pair is used, so this number
      # multiplies memory. Upstream's default is 4.
      LT_THREADS: "2"
      # One request cannot occupy a worker forever. 20000 characters is a long
      # document; upstream's default is no ceiling at all.
      LT_CHAR_LIMIT: "20000"
    volumes:
      - /srv/libretranslate/db:/app/db
      - libretranslate-models:/home/libretranslate/.local
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8154.
      - "127.0.0.1:8154:5000"
    healthcheck:
      # Upstream's own script. It exits 0 while /tmp/booting.flag exists, so the
      # container does not report unhealthy during the first model download.
      test: ["CMD-SHELL", "./venv/bin/python scripts/healthcheck.py"]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 900s

volumes:
  libretranslate-models:
Caddyfilethe hostname and TLS29 lines

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

# LibreTranslate · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.libretranslate.com/guides/installation/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. The application
# runs with LT_API_KEYS and LT_UNDER_ATTACK set, so it refuses a translation
# request carrying no key of its own. This block terminates TLS in front of that
# and adds no second credential: a browser password box would stop the API
# clients this install exists to serve.

<DOMAIN> {
	# The web page is HTML and the API answers JSON, and both compress well.
	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
	}

	# 8154 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8154
}
install.shthe same install, no agent166 lines

authored from upstream docs, never pasted · 8,092 bytes

#!/usr/bin/env bash
# LibreTranslate · 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=translate.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://docs.libretranslate.com/guides/installation/
#   https://docs.libretranslate.com/
#   https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/libretranslate/default_values.py
#   https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/docker/Dockerfile
#   https://github.com/LibreTranslate/LibreTranslate/blob/v1.9.6/scripts/entrypoint.sh
#
# One secret is generated here, on this machine: the API key every translation
# request has to carry. It goes into /srv/libretranslate/api-key with mode 600,
# is registered with the service over standard input so it never reaches a
# process list, and is never printed.
#
# The first start downloads about 2.1 GB of translation models before anything
# answers. The wait loop below allows fifteen minutes for that.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/libretranslate}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
KEY_FILE="${APP_DIR}/api-key"

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. translate.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; the translation models want 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; this install wants 10 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 ----------------------------------------------------
#
# db belongs to uid 1032 because the image creates its own account with that id
# and runs as it. There is no data directory: nothing translated is stored.

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

# --- 3. Generate the API key, on the server ----------------------------------
#
# Hex rather than base64: this value gets typed into settings boxes on other
# machines. The trailing newline is stripped so the file can be handed to curl
# and to the container verbatim. Read it later with
#   cat /srv/libretranslate/api-key

if [ ! -f "$KEY_FILE" ]; then
	umask 077
	openssl rand -hex 24 | tr -d '\n' > "$KEY_FILE"
	chmod 600 "$KEY_FILE"
	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-libretranslate"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$(dirname "$0")/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 8154 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; 8154 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 start downloads the 22 model packages named by LT_LOAD_ONLY before
# the service answers anything. Watch it with:
#   docker compose logs -f libretranslate

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/health (models download on the first start)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
	[ "$code" = "200" ] && break
	sleep 15
done
[ "${code:-}" = "200" ] || die "/health answered ${code:-nothing} after 15 minutes. Check: docker compose logs --tail 40 libretranslate"

curl -sS "https://${DOMAIN_HOST}/health" | grep -q '"status": *"ok"' \
	|| die "/health answered 200 without status ok. Check: docker compose logs --tail 40 libretranslate"

curl -sS "https://${DOMAIN_HOST}/languages" | grep -q '"code": *"es"' \
	|| die "/languages does not list Spanish, so the models did not install. Check: docker compose logs --tail 40 libretranslate"

# The key travels on standard input, so it is in no command line and no process
# list. 120 is this key's request limit per minute. The redirect discards the
# copy the tool would otherwise echo.
docker compose exec -T libretranslate sh -c 'ltmanage keys add 120 --key "$(cat)"' < "$KEY_FILE" > /dev/null

# The service must refuse a call that carries no key. Upstream answers 400 with
# a message telling the caller to ask the operator for one.
noauth="$(curl -sS -o /dev/null -w '%{http_code}' --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" "https://${DOMAIN_HOST}/translate" || true)"
[ "$noauth" = "400" ] || die "a keyless translate call returned ${noauth}, not 400. Stop and investigate."

# End to end: a sentence in, Spanish out.
curl -sS --data-urlencode "q=Hello world" --data-urlencode "source=en" --data-urlencode "target=es" --data-urlencode "api_key@${KEY_FILE}" "https://${DOMAIN_HOST}/translate" | grep -q '"translatedText"' \
	|| die "a keyed translate call did not return a translation. Check: docker compose logs --tail 40 libretranslate"

curl -sS "https://${DOMAIN_HOST}/" | grep -q 'Translation API' \
	|| die "the first screen does not carry the heading Translation API. Check that Caddy is reaching the container."

# --- 7. The first backup, before day one ends --------------------------------
#
# The models are not in it, on purpose: 2.1 GB of cache that downloads itself
# again. The Caddy file archived here is the live one, with the real hostname
# already substituted.

STAMP="$(date +%Y%m%d-%H%M%S)"
sudo tar -czf "$APP_DIR/backups/libretranslate-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml api-key db -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/libretranslate-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	LibreTranslate is answering at https://${DOMAIN_HOST}

	  1. The first screen is a text box under the heading Translation API. It
	     also carries a banner about bot abuse and API keys: that is upstream's
	     own wording for the mode this install runs in, not a fault.
	  2. Your API key is in $KEY_FILE, mode 600. Read it with
	       cat $KEY_FILE
	     and put it in your password manager. It was not printed here. In the
	     browser, click the key icon in the top bar and paste it once.
	  3. Eleven languages are installed: en, es, fr, de, it, pt, nl, pl, ru, zh
	     and ja, each paired with English and pivoted through it for the rest.
	     To change that set, edit LT_LOAD_ONLY in $APP_DIR/compose.yml and
	     restart; the new models download on the next start.
	  4. First backup written to $APP_DIR/backups: the key, the key database,
	     the compose file and the Caddy block. It is on the same disk as the
	     data, which is not a backup. Copy it somewhere else tonight, and treat
	     it as a secret, because it carries your key twice.

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 DeepL Pro.

  • The translations are worse, and that is the trade. DeepL's entire product is output quality; this runs Argos Translate on OpenNMT models that sit a tier below it on the common pairs and further below on languages with less training data behind them. For gist, internal documents, bulk pre-translation and anything a person edits afterwards, that is a fair deal. For a contract, a marketing page or anything a customer reads unedited, it is not, and no setting in this install changes it.
  • What you actually buy is that the text never leaves. Every character you paste into a translation service is sent to that service; every character you paste here is translated by a model on your own disk, with no account, no meter and no retention policy to read. If the reason you went looking was a document you are not allowed to upload anywhere, that reason is fully answered here even though the quality question is not.
  • The software is pinned and the models are not. The image ships no models: the first start downloads them from the Argos package index, which names each package by version and carries no checksum, so the digest in the compose file covers the program and not the thing doing the translating. A cache rebuilt six months from now can hold different models than the one you tested.
  • Eleven languages, not fifty. This install loads en, es, fr, de, it, pt, nl, pl, ru, zh and ja, about 2.1 GB; the whole index is 100 packages across 50 languages and about 8.4 GB. Every pair that does not involve English is translated by pivoting through English, which costs quality a second time.
  • There is no glossary and no tone control anywhere in the software. The entire option surface is the LT_ variables: limits, languages, keys, and the look of the web page. A product name you always want left untranslated is something you fix afterwards, by hand, every time.

Where this came from

“This work is largely possible thanks to Argos Translate, which powers the translation engine.”

  • LibreTranslate's translation engine is the open source Argos Translate library, so the output quality is Argos and OpenNMT's rather than a model LibreTranslate trains itself. source
  • The published image carries no language models. On the first boot, with fewer than two packages installed, the server updates the package index from a remote and downloads every package it offers for the requested languages. source
  • Every command-line argument is also an environment variable with an LT_ prefix, and every default is listed in one file, which is why this install is configured entirely through the compose environment block. source
  • With the key database enabled and under-attack mode on, a translate call carrying no key is refused with status 400 and a message telling the caller to contact the server operator. source
  • Upstream builds the CPU image for linux/amd64 and linux/arm64 and a separate -cuda image for amd64 only, so the plain tag is the one that runs on both architectures. source

Questions people actually ask

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

  • Can I self-host DeepL Pro?

    Not DeepL Pro 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 LibreTranslate. Machine translation on your own box, with no per-character meter and nothing sent to a vendor, at Argos model quality rather than DeepL'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 DeepL Pro?

    LibreTranslate. Machine translation on your own box, with no per-character meter and nothing sent to a vendor, at Argos model quality rather than DeepL's. The only option here with the shape of the thing you are cancelling: a translation API and a web text box you point your own tools at, running models on hardware you own. Be clear-eyed about what changes. The engine is Argos Translate on OpenNMT models, and on most pairs the output sits a tier below DeepL's, further below on languages with less training data behind them, and further still on pairs that have to pivot through English. What you get in exchange is that nothing you translate leaves the building, no meter runs per character, and the API answers with the network unplugged. For gist, internal documents and anything a person edits afterwards, that is a fair trade. For a contract or a customer-facing page, it is not, and this is the wrong page for that job. LibreTranslate is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to DeepL Pro?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. LibreTranslate itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: DeepL Pro Individual, $8.74/mo — $104.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 LibreTranslate install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run LibreTranslate on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same LibreTranslate on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. Worth knowing: Everything you paste is translated by models on this disk and none of it leaves the machine, which is the strongest version of the reason to do this at all; the cost is 2.1 GB of models downloaded once and a computer that has to be awake when you want a translation. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

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