# Can I self-host DeepL Pro?

**YES** — it's called LibreTranslate. ONE COMMAND setup · ~10 minutes to running · 2 GB RAM minimum · $8.74/mo you stop paying ($104.88/yr on the Individual plan).

LibreTranslate authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/deepl-pro/

## Install prompt (Claude Code)

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

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

Install 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.
````

## Chat fallback

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

You are installing 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.
````

## Local install prompt (your own computer, no server)

````text
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.
````

## docker-compose.yml

```yaml
# 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:
```

## compose.local.yml

```yaml
# 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:
```

## Caddyfile

```text
# 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.sh

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

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