Weblate Self-Hosted Setup

A self-hosted Weblate deploy fails most often at boot with Bad Request (400) or a web UI that loads but never translates — symptoms of a missing WEBLATE_ALLOWED_HOSTS value or a Celery worker that never connected to Redis. This guide walks the full Docker Compose stack — Weblate, Celery, Redis, PostgreSQL — through repository linking, webhook wiring, addons, and component configuration so that string changes flow from a Git push into translatable units without manual intervention.

Weblate is a copylefted, Django-based continuous-localization platform. Running it yourself keeps source strings, translation memory, and contributor data inside your own perimeter — the trade-off being that you now own Postgres tuning, queue health, and TLS. This setup is the open-source counterpart to a managed platform inside the broader Translation Workflows & CI/CD Pipeline Sync practice.

Weblate self-hosted container topology A Git push hits the webhook endpoint on the Weblate web container, which queues a repository update on Celery via Redis; Celery pulls the repo, parses files into translatable units, and persists them to PostgreSQL. Git host push / webhook weblate (web) Django + gunicorn Redis broker + cache celery worker VCS pull + parse PostgreSQL units + TM data volume /app/data repos
Push-to-translate flow: the web container queues work on Redis, Celery pulls and parses the repo, units land in Postgres.

Prerequisites

Concept & spec — what Weblate actually models

Weblate decomposes localization into projects → components → translations → units. A component binds to one VCS repository plus a file mask (a glob such as locales/*/messages.po) and a file format. Each matching file becomes a translation; each message in it becomes a unit. Weblate never invents the file layout — it reads whatever your extractor produced, which is why standardizing extraction with extracting translation keys with i18next-parser before the repo reaches Weblate prevents format drift between components.

Plural handling follows the Unicode CLDR plural rules (the same categories — zero, one, two, few, many, other — used across the localization stack), and language tags follow BCP 47 (RFC 5646). For PO files Weblate honours the gettext Plural-Forms header; for ICU MessageFormat resources it parses CLDR categories directly. This whole stack is one node in the parent Translation Workflows & CI/CD Pipeline Sync pipeline — Weblate owns the human-translation and review stage, while CI owns extraction and merge gating.

Step-by-step implementation

1. Generate the base environment

Weblate ships a Docker image (weblate/weblate) that bundles gunicorn, Celery, and the management commands. Create a project directory and an environment file holding the secrets the container reads at boot. Never hardcode these in docker-compose.yml.

mkdir -p weblate-deploy && cd weblate-deploy
cat > weblate.env <<'EOF'
WEBLATE_SITE_DOMAIN=weblate.example.com
WEBLATE_ADMIN_EMAIL=admin@example.com
WEBLATE_ADMIN_PASSWORD=change-me-now
WEBLATE_ALLOWED_HOSTS=weblate.example.com
POSTGRES_PASSWORD=pg-strong-secret
REDIS_PASSWORD=redis-strong-secret
WEBLATE_EMAIL_HOST=smtp.example.com
WEBLATE_EMAIL_HOST_USER=relay
WEBLATE_EMAIL_HOST_PASSWORD=relay-secret
EOF

2. Write the Compose stack

The web container and the Celery worker run the same image with different entry behaviour; the official image starts both gunicorn and an embedded Celery process per container, so a single weblate service covers most installs. Postgres and Redis get named volumes for persistence.

services:
  weblate:
    image: weblate/weblate:5.13
    ports: ["8080:8080"]
    env_file: weblate.env
    environment:
      POSTGRES_HOST: database
      REDIS_HOST: cache
    volumes:
      - weblate-data:/app/data
    depends_on: [database, cache]
    restart: unless-stopped

  database:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: weblate
      POSTGRES_DB: weblate
    env_file: weblate.env
    volumes: ["pg-data:/var/lib/postgresql/data"]

  cache:
    image: redis:7-alpine
    command: redis-server --requirepass "${REDIS_PASSWORD}" --save 60 1
    volumes: ["redis-data:/data"]

volumes:
  weblate-data:
  pg-data:
  redis-data:

3. Boot and verify the queue

Bring the stack up and watch the migrations apply. The first boot runs Django migrations and builds the search index, which can take a minute. The instance is healthy only once the Celery worker reports a heartbeat to Redis.

docker compose up -d
docker compose logs -f weblate | grep -E "migrat|celery|ready"
# Confirm the queue is alive — should print active worker nodes:
docker compose exec weblate weblate celery inspect ping

If celery inspect ping returns nothing, the worker is not draining its queue; that exact failure is covered in debugging a Weblate Celery queue with stuck translations.

4. Add a repository as a component

In the web UI choose Add new translation project, then Add component. Point it at your repository URL, supply the file mask and format, and set the push branch. Use a dedicated machine account, not a person’s credentials, so commit authorship and revocation stay clean.

# Equivalent via the API — create a component under project "app"
curl -X POST https://weblate.example.com/api/projects/app/components/ \
  -H "Authorization: Token $WEBLATE_TOKEN" \
  -d name="Frontend" -d slug="frontend" \
  -d repo="git@github.com:acme/app.git" \
  -d branch="main" -d push="git@github.com:acme/app.git" \
  -d filemask="locales/*/messages.po" -d file_format="po"

5. Wire the inbound webhook

Register a push webhook on the Git host pointing at https://weblate.example.com/hooks/github/ (or /gitlab/, /bitbucket/). On each push Weblate enqueues a pull instead of polling on a timer, collapsing sync latency to seconds. Set the webhook secret and enable Update on push in component settings. Auto-translation chaining off these events is detailed in configuring Weblate webhooks for auto-translation.

# Manually force a repository update (what the webhook triggers internally)
curl -X POST https://weblate.example.com/api/components/app/frontend/repository/ \
  -H "Authorization: Token $WEBLATE_TOKEN" -d operation=pull

6. Enable addons

Addons run server-side on commit/update events. Enable Cleanup translation files, Squash Git commits, and Add missing languages per component so machine-generated commits stay tidy and merge conflicts shrink.

curl -X POST https://weblate.example.com/api/components/app/frontend/addons/ \
  -H "Authorization: Token $WEBLATE_TOKEN" \
  -d name="weblate.git.squash" \
  -d 'configuration={"squash":"all"}'

Configuration reference

Option Type Description / default
WEBLATE_ALLOWED_HOSTS string (CSV) Hostnames Django will serve; blank → 400 Bad Request. Default localhost.
WEBLATE_SITE_DOMAIN string Canonical domain used in webhook and notification URLs. No default — required.
POSTGRES_HOST / REDIS_HOST string Service hostnames; default database / cache in the official Compose.
WEBLATE_WORKERS int gunicorn + Celery worker count; default derived from CPU. Raise for bulk imports.
filemask glob Per-component path glob, e.g. locales/*/app.json. Must contain exactly one *.
file_format enum po, json, xliff, yaml, arb, etc. Drives unit parsing and plural handling.
push string (URL) Write-enabled repo URL; omit to make the component read-only.
WEBLATE_ENABLE_HTTPS bool Sets secure cookies + HSTS. Default 0; set 1 behind a TLS proxy.

Framework variants

React / Next.js (i18next JSON). Set file_format=json (or i18nextv4 for v4 plural suffixes) and a mask like public/locales/*/common.json. Keep nested keys flat-or-consistent so the JSON parser maps cleanly to units.

Vue / Nuxt (JSON or YAML). vue-i18n SFC blocks are not directly parseable; export to standalone locales/*.json first, then point the component mask there.

Angular (XLIFF). Use file_format=xliff against src/locale/messages.*.xlf. Weblate preserves <context-group> metadata, so ng extract-i18n round-trips without losing source locations.

Node.js backend (gettext PO). file_format=po is the most feature-complete path: Weblate reads Plural-Forms, fuzzy flags, and translator comments natively, and the Cleanup addon keeps .po headers stable across commits.

Verification

After a push, confirm the unit count moved and the repository is clean. The API exposes both. In CI, fail the job if Weblate reports a merge conflict or uncommitted local changes.

# Expected: needs_commit=false, needs_merge=false, merge_failure=null
curl -s https://weblate.example.com/api/components/app/frontend/repository/ \
  -H "Authorization: Token $WEBLATE_TOKEN" | jq '{needs_commit, needs_merge, merge_failure}'

# Statistics — translated vs total strings for the component
curl -s https://weblate.example.com/api/components/app/frontend/statistics/ \
  -H "Authorization: Token $WEBLATE_TOKEN" | jq '.[] | {code, translated_percent}'

A green pipeline shows merge_failure: null and a rising translated_percent as translators work.

Common pitfalls

  • 400 Bad Request on first loadWEBLATE_ALLOWED_HOSTS does not include the host you browsed to. Add the FQDN and restart.
  • Translations never update after a push — the webhook is firing but Celery is not draining; inspect the queue per Weblate Celery queue stuck translations.
  • Permission denied (publickey) on push — the machine account’s SSH key is not registered as a deploy key with write access on the target repo.
  • Duplicate or orphaned units after refactors — extraction was inconsistent upstream; lock it down with extracting translation keys with i18next-parser.
  • Choosing self-host at all — if the operational load outweighs the sovereignty gain, weigh it against managed options in Crowdin vs Weblate for self-hosted teams.
  • Plural categories rendered wrong — the file_format does not match the resource; PO needs Plural-Forms, JSON needs the matching i18next variant.

FAQ

Do I need a separate container for the Celery worker?

Not for small-to-medium installs. The official weblate/weblate image starts gunicorn and an embedded Celery process in the same container, so one weblate service handles web and background work. Split Celery into its own service only when bulk imports or many large components saturate a single container — then run a second instance of the same image with the Celery entrypoint and shared volume.

Why is Redis required if Postgres already stores everything?

Redis is the Celery broker and the cache backend, not the system of record. Translation units, memory, and history live in PostgreSQL; Redis carries the queue of background jobs (VCS pulls, auto-translation, index updates) and short-lived cache entries. Without a reachable Redis, the web UI loads but no background work executes, so pushes appear to do nothing.

How does Weblate decide which files in a repo are translatable?

Each component declares a file mask (a glob with exactly one *, such as locales/*/messages.po) and a file format. Weblate matches files against the mask, treats the * segment as the language code, and parses each match with the declared format into translatable units. Files outside the mask are ignored entirely.

Can Weblate push translations back to my repository automatically?

Yes. Set the component’s push URL to a write-enabled remote and the machine account’s credentials must have push rights. Weblate commits translator changes locally, then pushes on its schedule or when you trigger a push via the UI/API. The Squash Git commits addon collapses many small translation commits into one to keep history readable.

Is HTTPS strictly required?

For any network-exposed instance, yes. Weblate issues session cookies and API tokens; over plain HTTP they are interceptable. Terminate TLS at your reverse proxy and set WEBLATE_ENABLE_HTTPS=1 so Django marks cookies secure and emits HSTS.

Part of Translation Workflows & CI/CD Pipeline Sync.