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, TLS termination, backups, 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 such as the one covered in the Crowdin integration workflow, and it slots into the broader Translation Workflows & CI/CD Pipeline Sync practice. Weblate owns the human-translation and review stage; extraction, format normalization, and merge gating happen upstream and downstream of it.

Weblate self-hosted container topology and push-to-translate flow A Git push hits the webhook endpoint on the Weblate web container, which enqueues a repository update on Redis; the Celery worker consumes the job, pulls and checks out the repo into the /app/data volume, parses files into translatable units, and writes those units and translation memory to PostgreSQL. Git host push / webhook weblate (web) Django + gunicorn Redis broker + cache celery worker VCS pull + parse data volume /app/data/vcs PostgreSQL units + TM push / webhook enqueue job deliver task pull / checkout write units
Push-to-translate flow: the web container enqueues work on Redis, the Celery worker pulls and checks out the repo into the data volume, parses it, and writes units into 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.

Responsibilities of the five Weblate containers The web container serves HTTP and the interface but schedules nothing itself. Celery beat schedules periodic jobs. The Celery workers perform commits, pushes and addon work, so translations reach git only through them. Redis holds the task queue. Postgres holds all translation state. What each Weblate container is responsible for Owns Stops working when weblate (web) HTTP and the UI nothing schedules itself celery-beat scheduling periodic jobs nightly tasks never fire celery workers commit, push, addons translations never reach git redis the task queue workers idle with a full queue postgres all translation state everything stops
Three of the five are invisible in the UI — which is why a stalled worker looks like a Weblate bug.

Under the hood, Weblate maintains its own Git checkout of each component under /app/data/vcs. It is not a passive mirror: it commits translator edits as real Git commits authored by the machine account, then pushes them to the push remote on a schedule or on demand. That makes the resolution chain explicit — an extractor writes source resources into the repo, CI merges them, Weblate pulls the merged state, translators fill units, and Weblate pushes the completed resources back. Where the source repo mixes formats, normalize them with PO/XLIFF format bridging before a component ever points at them, because a component’s file_format is fixed and cannot span two on-disk shapes.

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, so the plural skeleton a translator sees matches what the runtime formatter expects. 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

The path from a saved translation to a pushed commit A saved unit lands in Postgres at once, but the file on disk is written only after the lazy commit window elapses. Celery beat enqueues the commit task, a worker writes the file and commits it to the local clone, and the change reaches the remote only if push on commit is enabled or someone pushes manually. From a translator save to a git commit 1 Translator saves a unit written to Postgres immediately 2 Lazy commit window opens commit_pending_age, default 24h 3 Beat enqueues the commit task celery-beat, not the web container 4 A worker writes and commits to the local repository clone 5 Push runs, if configured push_on_commit or a manual push
Four of these five steps happen outside the web container — which is why "saved" is not "committed".

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. Chaining these events into automatic machine-translation pre-fill — so new units arrive with a draft translation for reviewers — 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"}'

7. Terminate TLS at a reverse proxy

The container serves plain HTTP on port 8080; never expose that directly. Put Nginx, Traefik, or Caddy in front, terminate TLS there, and set WEBLATE_ENABLE_HTTPS=1 so Django marks session cookies secure and emits HSTS. Forward the X-Forwarded-Proto header or Django will keep generating http:// URLs in webhook payloads and registration mail.

server {
  listen 443 ssl;
  server_name weblate.example.com;
  ssl_certificate     /etc/letsencrypt/live/weblate.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/weblate.example.com/privkey.pem;
  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
  }
}

8. Schedule backups

Translation memory and reviewer history live only in Postgres plus the /app/data volume — losing either loses work that cannot be re-extracted from source. Weblate’s built-in backup writes a consistent snapshot (database dump plus VCS repos and settings) to a target you configure; point it at object storage and run it on a timer.

# One-off consistent backup to the configured target
docker compose exec weblate weblate backup
# Or set a rolling target in weblate.env, then let the cron addon run it:
#   WEBLATE_BACKUP=1
#   WEBLATE_BACKUP_TARGET=s3://weblate-backups/prod

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.
WEBLATE_REGISTRATION_OPEN bool Whether strangers can self-register. Default 1; set 0 for a private instance.
WEBLATE_BACKUP / WEBLATE_BACKUP_TARGET bool / URL Enables the built-in backup and its destination (e.g. s3://…). No default.
WEBLATE_TIME_ZONE string IANA zone for timestamps and cron addons; default UTC.

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. If translators or downstream tools prefer PO, run the conversion through PO/XLIFF format bridging rather than pointing a component at both shapes.

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. Wire the same check into your merge gate so a component that is out of sync — or a locale that dropped below a translation threshold — blocks the release; the pattern for that lives in GitHub Actions i18n CI gates.

# .github/workflows/i18n-gate.yml — fail if Weblate has an unresolved merge conflict
- name: Assert Weblate repository is clean
  run: |
    state=$(curl -s "$WEBLATE_URL/api/components/app/frontend/repository/" \
      -H "Authorization: Token ${{ secrets.WEBLATE_TOKEN }}" \
      | jq -r '.merge_failure')
    test "$state" = "null" || { echo "Weblate merge conflict: $state"; exit 1; }

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.