Skip to content
Go back

LibreTranslate: Local Translation Without Google

By SumGuy 10 min read
LibreTranslate: Local Translation Without Google
Contents

So Google Translate Knows Your Business Literally

Every time you paste text into Google Translate, that text goes to Google. For a quick recipe or a tourist menu, sure, whatever. But if you’re translating customer support tickets, internal docs, medical notes, or literally anything that touches a user’s data, you’ve just handed it to a multi-billion-dollar ad company under a ToS that says, in so many words, “thanks for the training data.”

The Google Translate API is also pay-per-character past the free tier. DeepL is SaaS-only. Microsoft Translator wants an Azure account. All of them want your data living on their infrastructure.

LibreTranslate says no to all of that. It’s an open-source, self-hosted translation API running on Argos Translate models. Neural machine translation that runs entirely on your hardware, air-gapped if you want. Fifty-plus language pairs as of 2026, REST API, Docker-native, and it doesn’t phone home.

Let’s set it up.


What LibreTranslate Actually Is

LibreTranslate is a wrapper and API server around Argos Translate, which is itself a Python NMT (Neural Machine Translation) library. The models are standard .argosmodel packages, pre-trained on open datasets, downloadable, and self-contained. No runtime cloud calls, no API keys going out.

You get:

The catch: model quality is genuinely good for major language pairs (English ↔ French, Spanish, German, Chinese, Japanese) and noticeably weaker for low-resource languages. It’s not DeepL. It’s also not sending your company’s customer emails to Mountain View.


Docker Compose Setup

The key to keeping LibreTranslate usable on normal hardware is LT_LOAD_ONLY. Don’t load every model. Each language pair eats roughly 1 to 2 GB of RAM. Load only what you need.

docker-compose.yml
services:
libretranslate:
image: libretranslate/libretranslate:v1.9.5
container_name: libretranslate
restart: unless-stopped
ports:
- "5000:5000"
environment:
LT_LOAD_ONLY: "en,es,fr,de,zh,ja,pt,ru"
LT_THREADS: "4"
LT_CHAR_LIMIT: "5000"
LT_REQ_LIMIT: "100"
LT_BATCH_LIMIT: "25"
LT_API_KEYS: "false"
LT_DISABLE_WEB_UI: "false"
LT_UPDATE_MODELS: "false"
volumes:
- lt_models:/home/libretranslate/.local/share/argos-translate
- lt_db:/app/db
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
volumes:
lt_models:
lt_db:

A few things worth explaining:

LT_LOAD_ONLY: This is the most important env var. The format is a comma-separated list of language codes. LibreTranslate will only download and load models that involve those languages. English-to-French, French-to-English, etc. If you omit this, it tries to load every model it knows about and your box will have a bad time.

Set LT_UPDATE_MODELS to "false" after first run once your models are cached in the volume. On first boot, the container downloads the models (this takes a few minutes and several gigabytes depending on your language set). After that, you want it reading from volume, not re-downloading.

start_period: 120s: First boot takes a while. Don’t let your orchestrator restart-loop it while models are downloading.

LT_THREADS sets the CPU threads for translation. On a Pi 5 (4 cores), 4 is reasonable. On a real server, bump it.

First run, just do:

Terminal window
docker compose up -d
docker compose logs -f libretranslate

Watch the logs. You’ll see it downloading .argosmodel files. Once you see Running on http://0.0.0.0:5000, you’re ready.


The API Three Endpoints You Actually Need

GET /languages

Terminal window
curl http://localhost:5000/languages

Returns a JSON array of all loaded language pairs:

[
{"code": "en", "name": "English"},
{"code": "fr", "name": "French"},
{"code": "de", "name": "German"},
{"code": "es", "name": "Spanish"},
{"code": "zh", "name": "Chinese"},
{"code": "ja", "name": "Japanese"},
{"code": "pt", "name": "Portuguese"},
{"code": "ru", "name": "Russian"}
]

POST /translate

Terminal window
curl -s -X POST http://localhost:5000/translate \
-H "Content-Type: application/json" \
-d '{
"q": "The quick brown fox jumps over the lazy dog.",
"source": "en",
"target": "fr"
}'

Response:

{
"translatedText": "Le rapide renard brun saute par-dessus le chien paresseux."
}

For comparison, Google Translate returns: “Le renard brun rapide saute par-dessus le chien paresseux.”

Both are correct French. Word order differs slightly because French adjective placement varies by style. For everyday use, you won’t notice the difference. For literary translation, hire a human.

POST /detect

Terminal window
curl -s -X POST http://localhost:5000/detect \
-H "Content-Type: application/json" \
-d '{"q": "Hola mundo"}'
[
{"confidence": 0.99, "language": "es"}
]

Useful when your input language is unknown: pipe /detect first, then /translate.


Translating Large Blobs

LibreTranslate has a character limit (LT_CHAR_LIMIT, default 5000). For anything larger (a support ticket, a document, a log file), you need to chunk it.

The right way to chunk is at sentence boundaries, not arbitrary character counts. Cutting mid-sentence produces garbage translation at the seam.

Here’s a Python example using NLTK for sentence tokenization:

translate_doc.py
import requests
import nltk
nltk.download("punkt_tab", quiet=True)
from nltk.tokenize import sent_tokenize
LIBRETRANSLATE_URL = "http://localhost:5000/translate"
CHUNK_SIZE = 4000 # characters, stay under LT_CHAR_LIMIT
def chunk_sentences(text: str, max_chars: int) -> list[str]:
sentences = sent_tokenize(text)
chunks = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) + 1 > max_chars:
if current:
chunks.append(current.strip())
current = sentence
else:
current += " " + sentence
if current:
chunks.append(current.strip())
return chunks
def translate(text: str, source: str = "en", target: str = "fr") -> str:
chunks = chunk_sentences(text, CHUNK_SIZE)
translated_parts = []
for chunk in chunks:
resp = requests.post(
LIBRETRANSLATE_URL,
json={"q": chunk, "source": source, "target": target},
timeout=30,
)
resp.raise_for_status()
translated_parts.append(resp.json()["translatedText"])
return " ".join(translated_parts)
if __name__ == "__main__":
sample = """Your support ticket content goes here. It can be multiple paragraphs.
LibreTranslate will handle it in chunks, respecting sentence boundaries so
the output doesn't read like it was cut with a chainsaw."""
print(translate(sample, source="en", target="de"))

Performance Reality Check

Honestly? CPU-only translation is not instant. Here’s what to expect:

HardwareLanguage Pair~Time per sentence
Raspberry Pi 5 (4-core)en → fr~2s
Mid-range server (8-core)en → fr~0.3s
NVIDIA GPU (CUDA)en → fr<0.1s

For async workloads (translate a doc in the background, webhook when done), Pi 5 is fine. For real-time chat translation where a user is staring at a spinner, you want actual CPU headroom or a GPU.

GPU Acceleration

If you have an NVIDIA GPU with CUDA support, add the following to your Compose file:

docker-compose.yml (GPU addition)
services:
libretranslate:
# ... existing config ...
environment:
LT_LOAD_ONLY: "en,es,fr,de,zh,ja,pt,ru"
LT_THREADS: "8"
# CUDA is handled by the runtime below
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]

Make sure nvidia-container-toolkit is installed on the host. The image ships with CUDA support; it just needs the runtime exposed.


RAM Budget

Plan for roughly 1 to 2 GB per loaded language pair. “Language pair” here means directional: en→fr and fr→en are two pairs.

With LT_LOAD_ONLY: "en,es,fr,de,zh,ja,pt,ru":

If you’re on a 8 GB homelab box, trim your language list hard. English + Spanish + French is a very usable starting set that’ll stay under 4 GB.


Use Cases That Actually Make Sense

Nextcloud Talk Bot

If you’re running Nextcloud Talk as your team’s internal chat, you can wire LibreTranslate as the translation backend. Nextcloud 28+ supports pluggable translation providers. Point it at your LibreTranslate instance in the admin settings under “Artificial Intelligence” → “Translation provider.”

No setup beyond the URL. Your chat translations never leave your server.

Home Assistant TTS Pipeline

HA’s tts.speak action works in the language your TTS engine supports. If your engine doesn’t support a language, translate first:

automations.yaml (excerpt)
- alias: "Translate and speak alert"
trigger: ...
action:
- action: rest_command.libretranslate
data:
q: "Motion detected in the garage"
source: "en"
target: "es"
response_variable: translated
- action: tts.speak
target:
entity_id: media_player.kitchen_speaker
data:
message: "{{ translated.translatedText }}"

Define the rest_command.libretranslate in your configuration.yaml:

configuration.yaml (excerpt)
rest_command:
libretranslate:
url: "http://libretranslate:5000/translate"
method: POST
headers:
Content-Type: "application/json"
payload: '{"q": "{{ q }}", "source": "{{ source }}", "target": "{{ target }}"}'
content_type: "application/json"

Web Frontend Integration

The web UI is built in and reasonably clean for internal tools. If you want to embed translation in your own frontend:

translate.js
async function translateText(text, source, target) {
const response = await fetch("http://localhost:5000/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: text, source, target }),
});
const data = await response.json();
return data.translatedText;
}

Lock It Down: API Keys and Rate Limiting

Don’t expose an unauthenticated LibreTranslate instance to the internet. Anyone who finds it gets free translation on your hardware. Set LT_API_KEYS: "true" and manage keys through the admin UI, or via the SQLite DB it creates.

With API keys enabled, requests need an api_key field:

Terminal window
curl -X POST http://localhost:5000/translate \
-H "Content-Type: application/json" \
-d '{
"q": "Hello",
"source": "en",
"target": "fr",
"api_key": "your-key-here"
}'

For external exposure, put Caddy in front:

translate.yourdomain.com {
rate_limit {
zone translate {
key {remote_host}
events 10
window 1m
}
}
reverse_proxy libretranslate:5000
}

Heads up: rate_limit isn’t built into stock Caddy; you need a custom build with the caddy-ratelimit module (xcaddy build --with github.com/mholt/caddy-ratelimit). If you don’t want to build a custom binary, do the rate limiting in LibreTranslate itself via LT_REQ_LIMIT.


LibreTranslate vs. Bergamot vs. DeepL

Bergamot Translator is the engine behind Firefox Translations: in-browser, offline, privacy-preserving. It’s great if your use case is “a person using Firefox translating web pages.” It doesn’t give you an API, doesn’t run server-side, and has more limited language support. It and LibreTranslate are solving different problems.

DeepL has no self-hosted option. DeepL Pro is SaaS, the API is SaaS, and the quality is genuinely better than either of the above, but every query goes to their servers. If you need the quality and can accept the cloud dependency, use DeepL. If you need local, it’s not an option.

LibreTranslate lives in the middle: server-side API you control, good quality for major language pairs, open source, air-gappable. The tradeoff is CPU cost and slightly lower quality on edge cases.


Should You Bother?

Yes, if:

Probably not, if:

The RAM cost is real. The setup is real. But “I don’t want Google reading my support tickets” is also a real requirement, and LibreTranslate is currently the cleanest open-source answer to it.

Your 2 AM on-call self will appreciate not having to explain to legal why every customer complaint is in Google’s training dataset.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
crun vs runc vs youki: OCI Runtimes Compared
Next Post
RAGAS: Evaluating RAG Without Vibes

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts