Can I self-host Lokalise?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Weblate. It takes one prompt, a 3072 MB VPS, and about 120 minutes. That is $375 a month you stop paying Lokalise — $4,500 a year on the Growth plan.
Why people pay for Lokalise
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Lokalise sells the thing between your repository and a translated product: a place where developers push keys, translators fill them in without touching code, and the result comes back through an API, a CLI and integrations with the tools both sides already live in. The money is really for the middle of that sentence. Somebody has to keep the glossary and the translation memory consistent, run the review workflow, hand a designer a screenshot with the right string in it, and get professional translation ordered and delivered without anyone emailing a spreadsheet. Under the plan ladder it now sells, the meter is processed words per year rather than stored keys, and the seat counts are how many people get the advanced half of that.
| Plan | List price | What it buys |
|---|---|---|
| Explorer | $144/mo | Described on the vendor page as being for teams that want the essentials, done right and fast. The vendor help centre lists 5 advanced seats (10 maximum), unlimited basic seats, 60,000 processed words a year (500,000 maximum), 5 projects and 10 target languages. |
| Growththe plan this page prices against | $375/mo | Described as being for scaling teams that need better automation and control. The help centre lists 10 advanced seats (15 maximum), unlimited basic seats, 300,000 processed words a year (1.5 million maximum), and unlimited projects and target languages. An independent pricing tracker read on the same day puts this tier at $499 a month, which is the strongest sign that the figure recorded here is the annual-billed rate. |
| Advanced | $999/mo | Described as being for teams with complex, multi-product workflow and setups. The help centre lists 15 advanced seats, unlimited basic seats and 1 million processed words a year (3 million maximum). |
| Enterprise | quote only | Quote only; the page sends this tier to a demo booking. Described as being for global orgs with bespoke security and support needs. The help centre lists 40 advanced seats and 3 million processed words a year (15 million maximum). |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: medium
Replaced by Weblate
One project, named before the prompt, so you know what you are about to install.
Continuous localization that lives in your git repositories: translators work in a browser, Weblate commits the result back.
The one that replaces the workflow rather than the screen. Weblate's translations are commits in your own git repositories: it clones the repo, translators work in a browser, and it pushes the changed files back on a schedule or into a pull request, which is the same shape as the Lokalise integration you are paying for and the reason two decades of open source projects and agencies run it. It is GPL-3.0 with no feature held back, it has the format coverage and the quality checks that make a review workflow mean something, and it carries a translation memory and a glossary you own. The trade is honest: nobody sells you professional translation inside it, the machine-translation engines are accounts you bring, and the git write access has to be granted by hand once before any of it commits anything.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- Weblateour pickONE EVENING— setup effort 2 of 4
Continuous localization that lives in your git repositories: translators work in a browser, Weblate commits the result back.
The one that replaces the workflow rather than the screen. Weblate's translations are commits in your own git repositories: it clones the repo, translators work in a browser, and it pushes the changed files back on a schedule or into a pull request, which is the same shape as the Lokalise integration you are paying for and the reason two decades of open source projects and agencies run it. It is GPL-3.0 with no feature held back, it has the format coverage and the quality checks that make a review workflow mean something, and it carries a translation memory and a glossary you own. The trade is honest: nobody sells you professional translation inside it, the machine-translation engines are accounts you bring, and the git write access has to be granted by hand once before any of it commits anything.
- TolgeeONE EVENING— setup effort 2 of 4
Translation keys, in-context editing and an API your own app calls, on a hostname you own, with no key quota and no seat count.
The one to pick if what you actually liked about Lokalise was the editing experience rather than the git plumbing. Tolgee's distinctive move is in-context translation: an SDK in your app turns every string on the running page into something you can alt-click and fix, which closes the gap between seeing a bad label and fixing it faster than any file-based tool can. It ranks second here because that convenience is bought with a dependency in your application code, and because the repository-native, commit-it-back workflow that makes Weblate a drop-in for a Lokalise git integration is not the centre of what Tolgee does.
The swap
You'd run
Weblate
ONE EVENING · ~120 min to running · 3072 MB RAM
Lokalise Growth · vendor list price · checked 2026-08-07 · source · confidence: medium
Before you start
- RAM floor
- 3072 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~120 min1–3 hours, through the first backup
The prompt
Two paths to the same Weblate: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
345 lines · 14,978 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Weblate 2026.8.1.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until they
answer. `<DOMAIN>` becomes `WEBLATE_SITE_DOMAIN`, which upstream documents as required and which
every link Weblate prints is built out of, so its A record must already point at this server.
`<ADMIN_EMAIL>` goes on the one `admin` account this install creates.
Upstream states 3 GB of RAM as the floor for Weblate, its database and a web server on one host,
so this wants 3072 MB available and 10 GB free on /srv, and all three images run on amd64 and
arm64. Measure all four:
```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 3072 MB or free disk is under 10 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a name
that does not resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/weblate /srv/weblate/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/weblate/data /srv/weblate/cache
sudo install -d -m 700 /srv/weblate/postgres /srv/weblate/valkey
ls -la /srv/weblate
```
Assert: `ls -la` shows `backups` owned by the login user, `data` and `cache` owned by uid 1000,
and `postgres` and `valkey` at mode `700` owned by root. The Weblate image runs as uid 1000 and
stops with a permissions message when /app/data is not writable; the other two chown their own
data directory at start-up.
## 3. Secrets
Two secrets: the PostgreSQL password and the first password on the `admin` account. Generate both
on the server, print neither, and keep both out of your summary and out of every log line.
```bash
umask 077
cat > /srv/weblate/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WEBLATE_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/weblate/.env
umask 022
ls -l /srv/weblate/.env
```
Assert: the file exists with mode `-rw-------`. Upstream states that while that admin variable is
set the account is reset to match it on every start, and warns against keeping a password in
configuration, so step 7 removes the line once the user has signed in.
## 4. compose.yml
```bash
cat > /srv/weblate/compose.yml <<'EOF'
# Weblate · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.weblate.org/en/latest/admin/install/docker.html
# repository access .. https://docs.weblate.org/en/latest/vcs.html
# image .............. https://github.com/WeblateOrg/docker/blob/main/Dockerfile
#
# Three services: Weblate, the PostgreSQL holding every string and translation,
# and the Valkey carrying its cache and its Celery queue. Upstream runs the same
# three and reaches Valkey through REDIS_HOST, which is why the service is named
# for what it is and the variable is not. The Weblate image runs as uid 1000 and
# refuses to start when /app/data is not writable, so step 2 hands it that
# directory and /app/cache. Digests read on 2026-08-07, amd64 and arm64 both.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: weblate-db
restart: unless-stopped
environment:
POSTGRES_DB: weblate
POSTGRES_USER: weblate
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/weblate/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U weblate -d weblate"]
interval: 10s
retries: 12
# No `ports:`: 5432 only reaches the other containers.
valkey:
image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
container_name: weblate-cache
restart: unless-stopped
# Upstream's own line: one snapshot 60 seconds after a key changed.
command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"]
read_only: true
volumes:
- /srv/weblate/valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 12
# No `ports:`: 6379 never leaves the compose network.
weblate:
image: weblate/weblate:2026.8.1.0@sha256:44cd8cc84c41079fa9559d7f3cb7e9b80990f2b1ef975868423e322a507edc1b
container_name: weblate
restart: unless-stopped
env_file: /srv/weblate/.env
environment:
# Required upstream: every link Weblate prints is built out of it.
WEBLATE_SITE_DOMAIN: <DOMAIN>
WEBLATE_SITE_TITLE: Weblate
# localhost is listed because the image health-checks itself over it.
WEBLATE_ALLOWED_HOSTS: <DOMAIN>,localhost
WEBLATE_ADMIN_NAME: Weblate admin
WEBLATE_ADMIN_EMAIL: <ADMIN_EMAIL>
# Nobody signs themselves up: translators arrive on an invitation link.
WEBLATE_REGISTRATION_OPEN: "0"
# Caddy terminates TLS, so Weblate is told the outside is https.
WEBLATE_ENABLE_HTTPS: "1"
WEBLATE_SECURE_PROXY_SSL_HEADER: HTTP_X_FORWARDED_PROTO,https
WEBLATE_IP_PROXY_HEADER: HTTP_X_FORWARDED_FOR
# Upstream mails tracebacks to the admin by default; no mail here.
WEBLATE_ADMIN_NOTIFY_ERROR: "0"
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DB: weblate
POSTGRES_USER: weblate
REDIS_HOST: valkey
REDIS_PORT: "6379"
volumes:
- /srv/weblate/data:/app/data
- /srv/weblate/cache:/app/cache
# Everything written lands in the two mounts above. Upstream's own shape.
read_only: true
tmpfs:
- /run
- /tmp
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8173.
- "127.0.0.1:8173:8080"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
EOF
cd /srv/weblate && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Replace `<DOMAIN>` and `<ADMIN_EMAIL>` in the three places they
appear before running it. Compose fills `${POSTGRES_PASSWORD}` from `.env` here and hands the same
file to Weblate, so one value covers both ends of the connection string.
## 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-weblate
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Weblate · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.weblate.org/en/latest/admin/install/docker.html 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. That hostname is
# also WEBLATE_SITE_DOMAIN in compose.yml: Weblate builds every link it prints
# out of that value, so the two have to say the same thing.
<DOMAIN> {
encode zstd gzip
# No frame header here on purpose. Django sets X-Frame-Options itself,
# and one set at this layer would override the application's answer
# without the application knowing.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8173 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. Caddy sets
# X-Forwarded-For and X-Forwarded-Proto itself and ignores what the
# client sent, which is what WEBLATE_IP_PROXY_HEADER and
# WEBLATE_SECURE_PROXY_SSL_HEADER read. No upstream response timeout,
# so a first clone of a large repository has as long as it needs.
reverse_proxy 127.0.0.1:8173
}
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-weblate, reload, and report what it objected to. Caddy issues the
certificate on the first request and renews it on its own.
## 6. Firewall
Two ports open, both Caddy's. These commands are idempotent:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and 443/udp
is HTTP/3. 8173 is bound to 127.0.0.1 and 5432 and 6379 are never published at all. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule naming
8173, 5432 or 6379.
## 7. Start and verify
Weblate migrates its database, builds its static files and starts a web server, a Celery worker
and a scheduler inside one container. Its image sets a five-minute start period on its own health
check for that reason, which is why the loop below is long.
```bash
cd /srv/weblate
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>/healthz/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthz/
curl -sS https://<DOMAIN>/accounts/login/ | grep -c 'Sign in @ Weblate' || true
curl -sS https://<DOMAIN>/accounts/login/ | grep -c 'Register new account' || true
```
Assert, all four, and print what you received for each. The loop ends printing `200`. The health
endpoint answers `ok`. The third prints `1`, so the sign-in page is Weblate's, not Caddy's error
page. The fourth prints `0`, the security assert here: with registration closed the
`Register new account` link is absent, so nobody who finds this hostname can make themselves an
account. If any of the four misses, stop, run
`docker compose logs --tail 40 weblate`, and name the likely cause: a `502` over a log still
showing migrations wants more time, a permissions message about /app/data points at step 2, a
`400` points at `WEBLATE_ALLOWED_HOSTS`. A running container is not success.
The first screen at https://<DOMAIN>/accounts/login/ shows `Sign in to Weblate` over a username
and password form, with no register link.
STOP: tell the user to read their admin password with
`sudo grep WEBLATE_ADMIN_PASSWORD /srv/weblate/.env`, put it in their password manager, sign in at
https://<DOMAIN>/accounts/login/ as `admin`, and wait. Do not continue until they confirm.
Then take that password out of configuration, so a restart cannot reset the account to it:
```bash
sudo sed -i '/^WEBLATE_ADMIN_PASSWORD/d' /srv/weblate/.env
cd /srv/weblate && docker compose up -d --force-recreate weblate
sleep 60
grep -c WEBLATE_ADMIN_PASSWORD /srv/weblate/.env || true
curl -sS https://<DOMAIN>/healthz/
```
Assert: the count prints `0` and the health endpoint answers `ok` again. Both must pass before you
report success. Upstream leaves the account alone once that variable is gone; setting it again is
how a lost password gets reset.
## 8. First backup and restore
Two artifacts. The database holds every project, string, translation and user. The file archive
holds the data directory, where the cloned repositories, the translation memory and the VCS SSH
private key live, plus the files that rebuild the service.
```bash
cd /srv/weblate
docker compose exec -T postgres pg_dump -U weblate -d weblate | gzip > /srv/weblate/backups/weblate-db-$(date +%F).sql.gz
sudo tar -czf /srv/weblate/backups/weblate-files-$(date +%F).tar.gz -C /srv/weblate data compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/weblate/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. The cache directory and Valkey are left out:
one is rebuilt at every start.
A backup on the same disk is not a backup, so run this one from the user's machine:
```bash
mkdir -p ~/backups/weblate
scp vps:/srv/weblate/backups/* ~/backups/weblate/
```
To restore: `docker compose down`, `sudo rm -rf /srv/weblate/postgres /srv/weblate/data`, recreate
both as step 2 does, untar the file archive into /srv/weblate, `docker compose up -d postgres`,
wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U weblate -d weblate`, then `docker compose up -d`. Tell the
user what is in that archive: the SSH private key Weblate pushes with, which upstream says to keep
a backup of, because a lost one has to be re-authorised on every code host.
## 9. Updating later
New versions are listed at https://github.com/WeblateOrg/weblate/releases, and the matching
four-part image tag is on https://hub.docker.com/r/weblate/weblate. Take both backups first, then
edit the image line in /srv/weblate/compose.yml to the new tag and digest:
```bash
cd /srv/weblate
docker compose pull
docker compose up -d
docker compose logs --tail 30 weblate
```
Weblate migrates its own database on the way up, so watch that log until it settles, then re-run
step 7's health check. Upstream supports direct upgrades only from the current or the previous
calendar year, so a box left for three years needs a stop on the way.
## 10. What will probably go wrong
The first boot looks broken for several minutes and is not. I brought this up, watched Caddy answer
`502` for four and a half minutes, and had the Caddy log open before the page appeared. Nothing was
wrong: the container was migrating and collecting static files while its web server was not
listening yet, which is why upstream's image sets a five-minute start period on its health check.
Give step 7's loop its full ten minutes, and read `docker compose logs --tail 40 weblate` before
the proxy log.
## 11. Out of scope
- Do not add a project or component, and do not generate the VCS SSH key. Weblate makes that key
at https://<DOMAIN>/manage/ssh/, and pushing translations back needs its public half added on
the code host with write access, on an account the user holds and you do not.
- Do not configure SMTP or set any `WEBLATE_EMAIL_` variable. Registration is closed and the admin
adds people by copying an invitation link from Manage, so this runs without mail.
- Do not set any `WEBLATE_SOCIAL_AUTH_`, `WEBLATE_SAML_`, `WEBLATE_AUTH_LDAP_` or `WEBLATE_MT_`
variable. Each one is an account registered with somebody else, and none is needed to sign in
here or to translate.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Weblate 2026.8.1.0 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, and `<ADMIN_EMAIL>` with the address you want on the administrator account.
Weblate is a localization platform that keeps its translations in your git repositories. This
install gets you the server, one administrator account and closed registration. Connecting a
repository and letting Weblate push commits back is the job you do after it, in a browser, and
step 11 says why that part cannot be automated from here.
## 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 `3072` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: upstream states 3 GB of RAM as the floor for Weblate, its database and a web
server on one host, and the first boot is the hungriest moment of the install, so a 2 GB box
fails during the migration rather than later. 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.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/weblate /srv/weblate/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/weblate/data /srv/weblate/cache
sudo install -d -m 700 /srv/weblate/postgres /srv/weblate/valkey
ls -la /srv/weblate
```
You should see: `backups` owned by you, `data` and `cache` owned by `1000`, and `postgres` and
`valkey` at mode `drwx------` owned by root.
If you do not: the two uid-1000 directories are the ones that matter most. The Weblate image
runs as uid 1000 and prints a message about /app/data not being writable and exits when it
cannot write there, which looks like a crash and is a permission. Leave `postgres` and `valkey`
owned by root on purpose: each image chowns its own data directory the first time it starts, and
one you have already chowned to yourself makes PostgreSQL refuse to initialise.
## 3. Secrets
Two secrets are generated here, on the server, and both go into a file only you can read: the
PostgreSQL password and the first password on the `admin` account.
```bash
umask 077
cat > /srv/weblate/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WEBLATE_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 /srv/weblate/.env
umask 022
ls -l /srv/weblate/.env
```
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/weblate/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten both
values, which is fine before the database exists and a problem afterwards: PostgreSQL keeps the
password it was created with, so a changed one on an existing volume produces an authentication
failure in the Weblate log rather than anything that mentions passwords.
Do not paste that file, either secret, or any command output containing them into this chat
window. Read the admin password once in step 7, put it in your password manager, and let step 7
delete the line afterwards.
## 4. compose.yml
Paste the whole block at once, including the last two lines. Replace `<DOMAIN>` and
`<ADMIN_EMAIL>` in the three places they appear before you press enter.
```bash
cat > /srv/weblate/compose.yml <<'EOF'
# Weblate · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.weblate.org/en/latest/admin/install/docker.html
# repository access .. https://docs.weblate.org/en/latest/vcs.html
# image .............. https://github.com/WeblateOrg/docker/blob/main/Dockerfile
#
# Three services: Weblate, the PostgreSQL holding every string and translation,
# and the Valkey carrying its cache and its Celery queue. Upstream runs the same
# three and reaches Valkey through REDIS_HOST, which is why the service is named
# for what it is and the variable is not. The Weblate image runs as uid 1000 and
# refuses to start when /app/data is not writable, so step 2 hands it that
# directory and /app/cache. Digests read on 2026-08-07, amd64 and arm64 both.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: weblate-db
restart: unless-stopped
environment:
POSTGRES_DB: weblate
POSTGRES_USER: weblate
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/weblate/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U weblate -d weblate"]
interval: 10s
retries: 12
# No `ports:`: 5432 only reaches the other containers.
valkey:
image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
container_name: weblate-cache
restart: unless-stopped
# Upstream's own line: one snapshot 60 seconds after a key changed.
command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"]
read_only: true
volumes:
- /srv/weblate/valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 12
# No `ports:`: 6379 never leaves the compose network.
weblate:
image: weblate/weblate:2026.8.1.0@sha256:44cd8cc84c41079fa9559d7f3cb7e9b80990f2b1ef975868423e322a507edc1b
container_name: weblate
restart: unless-stopped
env_file: /srv/weblate/.env
environment:
# Required upstream: every link Weblate prints is built out of it.
WEBLATE_SITE_DOMAIN: <DOMAIN>
WEBLATE_SITE_TITLE: Weblate
# localhost is listed because the image health-checks itself over it.
WEBLATE_ALLOWED_HOSTS: <DOMAIN>,localhost
WEBLATE_ADMIN_NAME: Weblate admin
WEBLATE_ADMIN_EMAIL: <ADMIN_EMAIL>
# Nobody signs themselves up: translators arrive on an invitation link.
WEBLATE_REGISTRATION_OPEN: "0"
# Caddy terminates TLS, so Weblate is told the outside is https.
WEBLATE_ENABLE_HTTPS: "1"
WEBLATE_SECURE_PROXY_SSL_HEADER: HTTP_X_FORWARDED_PROTO,https
WEBLATE_IP_PROXY_HEADER: HTTP_X_FORWARDED_FOR
# Upstream mails tracebacks to the admin by default; no mail here.
WEBLATE_ADMIN_NOTIFY_ERROR: "0"
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DB: weblate
POSTGRES_USER: weblate
REDIS_HOST: valkey
REDIS_PORT: "6379"
volumes:
- /srv/weblate/data:/app/data
- /srv/weblate/cache:/app/cache
# Everything written lands in the two mounts above. Upstream's own shape.
read_only: true
tmpfs:
- /run
- /tmp
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8173.
- "127.0.0.1:8173:8080"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
EOF
cd /srv/weblate && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/weblate/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/weblate/compose.yml` and paste again in one go. A warning that
`POSTGRES_PASSWORD` is not set means you are not in /srv/weblate, which is where compose reads
`.env` from. The cache service is Valkey and the variable that points at it is `REDIS_HOST`,
which is not a typo: upstream's own compose file does the same, because Valkey speaks the Redis
protocol and Weblate's setting kept its old name.
## 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-weblate
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Weblate · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.weblate.org/en/latest/admin/install/docker.html 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. That hostname is
# also WEBLATE_SITE_DOMAIN in compose.yml: Weblate builds every link it prints
# out of that value, so the two have to say the same thing.
<DOMAIN> {
encode zstd gzip
# No frame header here on purpose. Django sets X-Frame-Options itself,
# and one set at this layer would override the application's answer
# without the application knowing.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8173 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. Caddy sets
# X-Forwarded-For and X-Forwarded-Proto itself and ignores what the
# client sent, which is what WEBLATE_IP_PROXY_HEADER and
# WEBLATE_SECURE_PROXY_SSL_HEADER read. No upstream response timeout,
# so a first clone of a large repository has as long as it needs.
reverse_proxy 127.0.0.1:8173
}
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-weblate /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the container, which is why
`WEBLATE_ENABLE_HTTPS` is `1` in the compose file: without it Weblate would build `http://`
links for a site that is only reachable over https, and those links go into pages people share.
## 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 `8173`, `5432` or `6379`.
If you do not: delete anything for those three with `sudo ufw delete allow 8173`. 8173 is bound
to 127.0.0.1 by the compose file, and 5432 and 6379 are never published at all, so the database
and the cache have no host port a firewall rule could apply to. 80/tcp is there to redirect to
HTTPS and to answer 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
Weblate migrates its database, builds its static files and starts a web server, a Celery worker
and a scheduler inside one container. Its image sets a five-minute start period on its own
health check for that reason. The loop below waits up to ten minutes; let it.
```bash
cd /srv/weblate
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>/healthz/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/healthz/
curl -sS https://<DOMAIN>/accounts/login/ | grep -c 'Sign in @ Weblate' || true
curl -sS https://<DOMAIN>/accounts/login/ | grep -c 'Register new account' || true
```
You should see, in order: the loop climbing through `502` and ending on `200`, then the two
characters `ok`, then `1`, then `0`.
If you do not: the `0` on the last line is the one worth understanding. It means the sign-in page
carries no `Register new account` link, so registration really is closed and nobody who finds
this hostname can make themselves an account. A `1` there means `WEBLATE_REGISTRATION_OPEN` did
not reach the container, and you should fix that before going any further. If the loop never
reaches `200`, run `docker compose logs --tail 40 weblate`: a log still printing migration lines
wants more time, a permissions message about /app/data is step 2 done wrong, and a `400` instead
of a `200` means `WEBLATE_ALLOWED_HOSTS` in step 4 does not carry your hostname.
Now open https://<DOMAIN>/accounts/login/ in a browser. The first screen shows the heading
`Sign in to Weblate` over a username and password field, with no register link under it. Read
your admin password on the server and sign in as the username `admin`. Do not change it in the
browser yet; the next block is what makes a change stick:
```bash
sudo grep WEBLATE_ADMIN_PASSWORD /srv/weblate/.env
```
You should see: one line, and you should put its value in your password manager rather than in
this chat window.
If you do not: an empty result means step 3 wrote the file somewhere else. Check
`ls -l /srv/weblate/.env`.
Once you are signed in, take the password out of the configuration file, because while it is
there Weblate resets the account to it on every container start, which quietly undoes any
password you set in the browser. After this, your account page is yours:
```bash
sudo sed -i '/^WEBLATE_ADMIN_PASSWORD/d' /srv/weblate/.env
cd /srv/weblate && docker compose up -d --force-recreate weblate
sleep 60
grep -c WEBLATE_ADMIN_PASSWORD /srv/weblate/.env || true
curl -sS https://<DOMAIN>/healthz/
```
You should see: `0`, then `ok`.
If you do not: a count above `0` means the line is still there, so check the file. If `ok` does
not come back, the container is still restarting; wait a minute and run the last line again.
Upstream leaves the account alone at start-up once that variable is gone, and putting it back
with a new value is the documented way to reset a lost admin password.
## 8. First backup and restore
Two artifacts. The database holds every project, string, translation and user. The file archive
holds Weblate's data directory, where the cloned repositories, the translation memory and the
VCS SSH private key live, plus the files that rebuild the service around them.
```bash
cd /srv/weblate
docker compose exec -T postgres pg_dump -U weblate -d weblate | gzip > /srv/weblate/backups/weblate-db-$(date +%F).sql.gz
sudo tar -czf /srv/weblate/backups/weblate-files-$(date +%F).tar.gz -C /srv/weblate data compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/weblate/backups/
```
You should see: two files, both a few kilobytes on a fresh install. Nothing goes offline:
`pg_dump` snapshots a running database consistently.
If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.
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/weblate
scp vps:/srv/weblate/backups/* ~/backups/weblate/
```
You should see: two files copied, and both listed by `ls -lh ~/backups/weblate/`.
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 `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty install:
```bash
cd /srv/weblate
docker compose down
sudo rm -rf /srv/weblate/postgres
sudo install -d -m 700 /srv/weblate/postgres
docker compose up -d postgres
sleep 30
gunzip -c /srv/weblate/backups/weblate-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U weblate -d weblate
docker compose up -d
sleep 120
curl -sS https://<DOMAIN>/healthz/
```
You should see: `CREATE TABLE` and `COPY` lines from psql, then `ok` from the last command, and
your admin account still signs in.
If you do not: `role "weblate" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the archive is
worth before you skip this: it carries the SSH private key Weblate pushes commits with, and
upstream says plainly to keep a backup of that key, because it cannot carry a passphrase and a
lost one has to be re-authorised on every code host you had connected.
## 9. Updating later
New versions are listed at https://github.com/WeblateOrg/weblate/releases, and the matching
four-part image tag is on https://hub.docker.com/r/weblate/weblate. Take both backup artifacts
first, then edit the `image:` line in /srv/weblate/compose.yml to the new tag and its digest.
```bash
cd /srv/weblate
docker compose pull
docker compose up -d
docker compose logs --tail 30 weblate
```
You should see: migration output, then 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
health check from step 7 before you call the update done. Upstream supports direct upgrades only
from the current or the previous calendar year, so if you have left this alone for longer you
have to stop at an intermediate release rather than jumping to the newest one.
## 10. What will probably go wrong
The first boot looks broken for several minutes and is not. I brought this up, watched Caddy
answer `502` for four and a half minutes, and had the Caddy log open before the page appeared.
Nothing was wrong: the container was migrating and collecting static files while its web server
was not listening yet, which is why upstream's image sets a five-minute start period on its
health check. Give step 7's loop its full ten minutes, and read
`docker compose logs --tail 40 weblate` before the proxy log.
## 11. Out of scope
- Do not add a project or component, and do not generate the VCS SSH key, from this prompt.
Weblate makes that key at https://<DOMAIN>/manage/ssh/, and pushing translations back needs
its public half added on the code host with write access, on an account only you hold. That is
your first job after this, and it is the whole reason to run Weblate rather than a spreadsheet.
- Do not configure SMTP or set any `WEBLATE_EMAIL_` variable. Registration is closed and you add
people by copying an invitation link from Manage, so this install runs without mail.
- Do not set any `WEBLATE_SOCIAL_AUTH_`, `WEBLATE_SAML_`, `WEBLATE_AUTH_LDAP_` or `WEBLATE_MT_`
variable. Each one is an account registered with somebody else, and none is needed to sign in
here or to translate.337 lines · 14,976 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Weblate 2026.8.1.0, with the PostgreSQL and Valkey it needs, under ~/selfhost/weblate,
answering at http://localhost:8173.
## 1. Preflight
Say this to the user before step 2 runs; it decides whether they want this install at all. Weblate
answers on this computer and nowhere else, so no translator they invite can open it, and it polls
the repositories it watches only while this machine is awake. What they keep is their own strings,
their own translation memory, and commits pushed back to their own git remotes.
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 too, for step 2. Upstream states 3 GB of RAM as the floor for
Weblate, its database and a web server on one host, so this wants 3072 MB available and 10 GB free
on the home disk; all three images publish amd64 and arm64. If RAM is under 3072 MB or disk under
10 GB, print both numbers and stop. On macOS and Windows, Docker Desktop's virtual machine has its
own memory allocation and everything here runs inside it: have the user set it to 4 GB in
Settings, Resources.
## 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/weblate/data ~/selfhost/weblate/cache ~/selfhost/weblate/backups
if [ "$(uname -s)" = "Linux" ]; then
sudo chown -R 1000:1000 ~/selfhost/weblate/data ~/selfhost/weblate/cache
fi
ls -la ~/selfhost/weblate
```
Assert: `ls -la` shows `data`, `cache` and `backups`. The Weblate image runs as uid 1000 and stops
when /app/data is not writable, so on Linux those two go to that uid; the guard skips on macOS and
Windows, where Docker Desktop handles it.
## 4. Secrets
Two secrets: the PostgreSQL password and the first password on the `admin` account. Generate both
here, print neither, and keep both out of your summary and out of every log line.
```bash
umask 077
cat > ~/selfhost/weblate/.env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WEBLATE_ADMIN_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 ~/selfhost/weblate/.env
umask 022
ls -l ~/selfhost/weblate/.env
```
Assert: the file exists with mode `-rw-------`; Git Bash ships openssl. Upstream resets the
account to that variable on every start, so step 7 removes the line. On Windows those mode bits
are advisory: the real boundary is the user's own Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/weblate/compose.yml <<'EOF'
# Weblate · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://docs.weblate.org/en/latest/admin/install/docker.html
# repository access .. https://docs.weblate.org/en/latest/vcs.html
# image .............. https://github.com/WeblateOrg/docker/blob/main/Dockerfile
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/weblate/, so one file works on macOS, Linux and Windows. The
# database and the cache are named volumes, not folders you can open: both
# images chown their own data directory and a home-directory bind mount cannot
# grant that on Windows. Weblate's data and cache stay real folders it writes
# as uid 1000. Digests read 2026-08-07, amd64 and arm64; no TLS anywhere.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: weblate-db
restart: unless-stopped
environment:
POSTGRES_DB: weblate
POSTGRES_USER: weblate
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- weblate-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U weblate -d weblate"]
interval: 10s
retries: 12
# No `ports:`: 5432 stays on the compose network.
valkey:
image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
container_name: weblate-cache
restart: unless-stopped
command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"]
read_only: true
volumes:
- weblate-valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 12
weblate:
image: weblate/weblate:2026.8.1.0@sha256:44cd8cc84c41079fa9559d7f3cb7e9b80990f2b1ef975868423e322a507edc1b
container_name: weblate
restart: unless-stopped
env_file: ./.env
environment:
WEBLATE_SITE_DOMAIN: "localhost:8173"
WEBLATE_SITE_TITLE: Weblate
WEBLATE_ALLOWED_HOSTS: localhost,127.0.0.1
WEBLATE_ADMIN_NAME: Weblate admin
WEBLATE_ADMIN_EMAIL: admin@example.com
# Nobody signs themselves up: translators arrive on an invitation link.
WEBLATE_REGISTRATION_OPEN: "0"
WEBLATE_ADMIN_NOTIFY_ERROR: "0"
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DB: weblate
POSTGRES_USER: weblate
REDIS_HOST: valkey
REDIS_PORT: "6379"
volumes:
- ./data:/app/data
- ./cache:/app/cache
read_only: true
tmpfs:
- /run
- /tmp
ports:
# Loopback only: no other device can reach 8173.
- "127.0.0.1:8173:8080"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
volumes:
weblate-pgdata:
weblate-valkey:
EOF
cd ~/selfhost/weblate && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Compose fills `${POSTGRES_PASSWORD}` from `.env` here.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. There is no hostname to resolve; a certificate
attests a public name and nothing here has one, and browsers treat http://localhost as a secure
context anyway; nothing is published beyond loopback, so no port needs closing. 8173 is bound to
127.0.0.1: not the user's phone, not a laptop on the wifi, nobody on the internet. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/weblate/compose.yml
```
Assert: that prints `1`, the one published port `- "127.0.0.1:8173:8080"`. PostgreSQL and Valkey
publish no host port.
## 7. Start and verify
Weblate migrates, collects static files and starts a web server, a Celery worker and a scheduler
in one container, which is why its image sets a five-minute health-check start period and the loop
below is long.
```bash
cd ~/selfhost/weblate
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:8173/healthz/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8173/healthz/
curl -sS http://localhost:8173/accounts/login/ | grep -c 'Sign in @ Weblate' || true
curl -sS http://localhost:8173/accounts/login/ | grep -c 'Register new account' || true
```
Assert all four, and print what you received for each: the loop ends on `200`; the health endpoint
answers `ok`; the third prints `1`; the fourth prints `0`, the security assert here, because
registration is closed and the `Register new account` link is absent. If any misses, stop, run
`docker compose logs --tail 40 weblate`, and name the likely cause: an empty `POSTGRES_PASSWORD`
from step 4 stops the database, a log still in migrations wants more time, exit code `137` is
step 10, and `port is already allocated` means the user has to free 8173. A running container is
not success.
The first screen at http://localhost:8173/accounts/login/ shows `Sign in to Weblate` over a
username and password form, with no register link.
STOP: tell the user to read their admin password with
`grep WEBLATE_ADMIN_PASSWORD ~/selfhost/weblate/.env`, put it in their password manager, sign in
at http://localhost:8173/accounts/login/ as `admin`, and wait. Do not continue until they confirm.
Then take that password out of configuration, so a restart cannot reset the account to it:
```bash
sed -i.bak '/^WEBLATE_ADMIN_PASSWORD/d' ~/selfhost/weblate/.env
rm -f ~/selfhost/weblate/.env.bak
cd ~/selfhost/weblate && docker compose up -d --force-recreate weblate
sleep 60
grep -c WEBLATE_ADMIN_PASSWORD ~/selfhost/weblate/.env || true
curl -sS http://localhost:8173/healthz/
```
Assert: the count prints `0` and the health endpoint answers `ok` again. Both must pass before
you report success.
## 8. First backup and restore
Two artifacts: a database dump with every project, string, translation and user, and a file
archive with the data directory, where the cloned repositories, the translation memory and the VCS
private key live.
```bash
cd ~/selfhost/weblate
docker compose exec -T postgres pg_dump -U weblate -d weblate | gzip > ~/selfhost/weblate/backups/weblate-db-$(date +%F).sql.gz
tar -C ~/selfhost/weblate -czf ~/selfhost/weblate/backups/weblate-files-$(date +%F).tar.gz data compose.yml .env
ls -lh ~/selfhost/weblate/backups/
```
Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `pg_dump`
snapshots a running database consistently. On Linux a permission error from `tar` means the login
user is not uid 1000, and `sudo` in front of that line fixes it.
Both archives sit on the same disk as the data, 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 synced
folder or a USB stick, and copy both there with `cp`. Assert: the user confirms both are there,
or say plainly that this install has no backup.
To restore: `cd ~/selfhost/weblate`, untar the file archive there first so compose.yml and .env
are back before any container starts, because PostgreSQL takes its password from .env the moment
it initialises an empty volume. Then `docker compose down -v`, the one place `-v` belongs,
`docker compose up -d postgres`, wait 30 seconds, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U weblate -d weblate`, then `docker compose up -d`.
## 9. Updating later
New versions are at https://github.com/WeblateOrg/weblate/releases and the matching four-part
image tag is on https://hub.docker.com/r/weblate/weblate. Back up first, then edit the image line
in compose.yml to the new tag and digest:
```bash
cd ~/selfhost/weblate
docker compose pull
docker compose up -d
docker compose logs --tail 30 weblate
```
Watch that log until it settles, then re-run step 7's health check. Upstream supports direct
upgrades only within the current or previous calendar year.
## 10. What will probably go wrong
Memory, on a machine with plenty of it. My laptop had 9 GB free and the container still died
partway through its first start, with exit code `137` in `docker compose ps` and nothing useful in
the log. Docker Desktop had 2 GB assigned to its virtual machine, everything here runs inside that
machine, and upstream asks for 3 GB, so the migration was killed by a limit the host never felt.
Settings, Resources, memory to 4 GB, restart, `docker compose up -d`. Any later `137` is this.
## 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 add a project or component, and do not generate the VCS key. Weblate makes that key at
http://localhost:8173/manage/, under SSH keys, and pushing back needs its public half on the
code host, on an account the user holds.
- Do not configure SMTP and do not set any `WEBLATE_SOCIAL_AUTH_`, `WEBLATE_SAML_`,
`WEBLATE_AUTH_LDAP_` or `WEBLATE_MT_` variable. Each is an account somewhere else.compose.local.ymlthe services, pinned · local layout84 lines
# Weblate · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://docs.weblate.org/en/latest/admin/install/docker.html
# repository access .. https://docs.weblate.org/en/latest/vcs.html
# image .............. https://github.com/WeblateOrg/docker/blob/main/Dockerfile
#
# Three services on the computer you are sitting at. Paths are relative to
# ~/selfhost/weblate/, so one file works on macOS, Linux and Windows. The
# database and the cache are named volumes, not folders you can open: both
# images chown their own data directory and a home-directory bind mount cannot
# grant that on Windows. Weblate's data and cache stay real folders it writes
# as uid 1000. Digests read 2026-08-07, amd64 and arm64; no TLS anywhere.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: weblate-db
restart: unless-stopped
environment:
POSTGRES_DB: weblate
POSTGRES_USER: weblate
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- weblate-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U weblate -d weblate"]
interval: 10s
retries: 12
# No `ports:`: 5432 stays on the compose network.
valkey:
image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
container_name: weblate-cache
restart: unless-stopped
command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"]
read_only: true
volumes:
- weblate-valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 12
weblate:
image: weblate/weblate:2026.8.1.0@sha256:44cd8cc84c41079fa9559d7f3cb7e9b80990f2b1ef975868423e322a507edc1b
container_name: weblate
restart: unless-stopped
env_file: ./.env
environment:
WEBLATE_SITE_DOMAIN: "localhost:8173"
WEBLATE_SITE_TITLE: Weblate
WEBLATE_ALLOWED_HOSTS: localhost,127.0.0.1
WEBLATE_ADMIN_NAME: Weblate admin
WEBLATE_ADMIN_EMAIL: admin@example.com
# Nobody signs themselves up: translators arrive on an invitation link.
WEBLATE_REGISTRATION_OPEN: "0"
WEBLATE_ADMIN_NOTIFY_ERROR: "0"
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DB: weblate
POSTGRES_USER: weblate
REDIS_HOST: valkey
REDIS_PORT: "6379"
volumes:
- ./data:/app/data
- ./cache:/app/cache
read_only: true
tmpfs:
- /run
- /tmp
ports:
# Loopback only: no other device can reach 8173.
- "127.0.0.1:8173:8080"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
volumes:
weblate-pgdata:
weblate-valkey:agent-readable mirror: /self-host/lokalise.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned90 lines
# Weblate · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://docs.weblate.org/en/latest/admin/install/docker.html
# repository access .. https://docs.weblate.org/en/latest/vcs.html
# image .............. https://github.com/WeblateOrg/docker/blob/main/Dockerfile
#
# Three services: Weblate, the PostgreSQL holding every string and translation,
# and the Valkey carrying its cache and its Celery queue. Upstream runs the same
# three and reaches Valkey through REDIS_HOST, which is why the service is named
# for what it is and the variable is not. The Weblate image runs as uid 1000 and
# refuses to start when /app/data is not writable, so step 2 hands it that
# directory and /app/cache. Digests read on 2026-08-07, amd64 and arm64 both.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
postgres:
image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
container_name: weblate-db
restart: unless-stopped
environment:
POSTGRES_DB: weblate
POSTGRES_USER: weblate
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/weblate/postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U weblate -d weblate"]
interval: 10s
retries: 12
# No `ports:`: 5432 only reaches the other containers.
valkey:
image: valkey/valkey:9.1.1-alpine@sha256:ee91f7a174ac4d6a6b0685b3a60e321f0a9dbbb691f9b0e285be2ba1d1be8328
container_name: weblate-cache
restart: unless-stopped
# Upstream's own line: one snapshot 60 seconds after a key changed.
command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"]
read_only: true
volumes:
- /srv/weblate/valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
retries: 12
# No `ports:`: 6379 never leaves the compose network.
weblate:
image: weblate/weblate:2026.8.1.0@sha256:44cd8cc84c41079fa9559d7f3cb7e9b80990f2b1ef975868423e322a507edc1b
container_name: weblate
restart: unless-stopped
env_file: /srv/weblate/.env
environment:
# Required upstream: every link Weblate prints is built out of it.
WEBLATE_SITE_DOMAIN: <DOMAIN>
WEBLATE_SITE_TITLE: Weblate
# localhost is listed because the image health-checks itself over it.
WEBLATE_ALLOWED_HOSTS: <DOMAIN>,localhost
WEBLATE_ADMIN_NAME: Weblate admin
WEBLATE_ADMIN_EMAIL: <ADMIN_EMAIL>
# Nobody signs themselves up: translators arrive on an invitation link.
WEBLATE_REGISTRATION_OPEN: "0"
# Caddy terminates TLS, so Weblate is told the outside is https.
WEBLATE_ENABLE_HTTPS: "1"
WEBLATE_SECURE_PROXY_SSL_HEADER: HTTP_X_FORWARDED_PROTO,https
WEBLATE_IP_PROXY_HEADER: HTTP_X_FORWARDED_FOR
# Upstream mails tracebacks to the admin by default; no mail here.
WEBLATE_ADMIN_NOTIFY_ERROR: "0"
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
POSTGRES_DB: weblate
POSTGRES_USER: weblate
REDIS_HOST: valkey
REDIS_PORT: "6379"
volumes:
- /srv/weblate/data:/app/data
- /srv/weblate/cache:/app/cache
# Everything written lands in the two mounts above. Upstream's own shape.
read_only: true
tmpfs:
- /run
- /tmp
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8173.
- "127.0.0.1:8173:8080"
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthyCaddyfilethe hostname and TLS32 lines
# Weblate · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.weblate.org/en/latest/admin/install/docker.html 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. That hostname is
# also WEBLATE_SITE_DOMAIN in compose.yml: Weblate builds every link it prints
# out of that value, so the two have to say the same thing.
<DOMAIN> {
encode zstd gzip
# No frame header here on purpose. Django sets X-Frame-Options itself,
# and one set at this layer would override the application's answer
# without the application knowing.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# 8173 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. Caddy sets
# X-Forwarded-For and X-Forwarded-Proto itself and ignores what the
# client sent, which is what WEBLATE_IP_PROXY_HEADER and
# WEBLATE_SECURE_PROXY_SSL_HEADER read. No upstream response timeout,
# so a first clone of a large repository has as long as it needs.
reverse_proxy 127.0.0.1:8173
}install.shthe same install, no agent172 lines
#!/usr/bin/env bash
# Weblate · 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=weblate.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://docs.weblate.org/en/latest/admin/install/docker.html
# https://docs.weblate.org/en/latest/admin/install.html
# https://docs.weblate.org/en/latest/vcs.html
# https://caddyserver.com/docs/automatic-https
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# the first password on the admin account. Both go into /srv/weblate/.env with
# mode 600 and neither is ever printed.
#
# DOMAIN_HOST is also WEBLATE_SITE_DOMAIN. Weblate builds every link it prints
# out of it, so choose the hostname you intend to keep.
#
# This script stops short of one thing on purpose: only a human at a browser can
# sign in for the first time, and the closing summary says how to do that and
# how to take the admin password back out of the configuration afterwards.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/weblate}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
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. weblate.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address you want on the administrator account"
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 3072 ] || die "only ${avail_mb} MB of RAM available; upstream states 3 GB for Weblate, its database and a web server"
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 ----------------------------------------------------
#
# Three owners, three reasons. The Weblate image runs as uid 1000 and exits with
# a message about /app/data when it cannot write there, so data and cache are
# handed to that uid. The PostgreSQL and Valkey images each chown their own data
# directory at start-up, so those two stay root-owned and untouched.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/data" "$APP_DIR/cache"
sudo install -d -m 700 "$APP_DIR/postgres" "$APP_DIR/valkey"
rendered="$(mktemp)"
sed -e "s|<DOMAIN>|${DOMAIN_HOST}|g" -e "s|<ADMIN_EMAIL>|${ADMIN_EMAIL}|g" \
"$(dirname "$0")/compose.yml" > "$rendered"
install -m 0644 "$rendered" "$APP_DIR/compose.yml"
rm -f "$rendered"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex for the database password because it is interpolated into a connection
# string. Read them later with
# sudo grep -E 'POSTGRES_PASSWORD|WEBLATE_ADMIN_PASSWORD' /srv/weblate/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
POSTGRES_PASSWORD=$(openssl rand -hex 32)
WEBLATE_ADMIN_PASSWORD=$(openssl rand -base64 24)
ENVFILE
chmod 600 "$APP_DIR/.env"
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-weblate"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/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 none of 8173, 5432 or 6379 is 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; 8173, 5432 and 6379 stay 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 migrates the database and collects static files behind a web
# server that is not listening yet, which is why the image carries a five-minute
# health-check start period and why this loop waits ten.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/healthz/ (this takes minutes on a first boot)"
for _ in $(seq 1 60); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthz/" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/healthz/ answered ${code:-nothing}. Check: docker compose logs --tail 40 weblate"
curl -sS "https://${DOMAIN_HOST}/healthz/" | grep -qx 'ok' \
|| die "/healthz/ answered 200 without the body ok. Check: docker compose logs --tail 40 weblate"
login="$(curl -sS "https://${DOMAIN_HOST}/accounts/login/" || true)"
printf '%s' "$login" | grep -q 'Sign in @ Weblate' \
|| die "the sign-in page is not Weblate's. Check: docker compose logs --tail 40 weblate"
# The security assert: with registration closed there is no register link, so
# nobody who finds this hostname can create an account.
if printf '%s' "$login" | grep -q 'Register new account'; then
die "the sign-in page still offers Register new account. WEBLATE_REGISTRATION_OPEN did not reach the container."
fi
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U weblate -d weblate | gzip > "$APP_DIR/backups/weblate-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/weblate-files-${STAMP}.tar.gz" -C "$APP_DIR" data compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/weblate-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
cat <<-DONE
Weblate is answering at https://${DOMAIN_HOST}/healthz/
1. Open https://${DOMAIN_HOST}/accounts/login/ in a browser. The first
screen reads "Sign in to Weblate" and carries no register link,
because registration is closed. Sign in as the username admin.
Read the password yourself, on this server, with
sudo grep WEBLATE_ADMIN_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here.
2. Then take that line out of the configuration, because while it is
there Weblate resets the account to it on every container start:
sudo sed -i '/^WEBLATE_ADMIN_PASSWORD/d' $APP_DIR/.env
cd $APP_DIR && docker compose up -d --force-recreate weblate
Putting it back with a new value is how you reset a lost password.
3. Next comes the part this script cannot do. Weblate pushes
translations back to your git repositories, and that needs a key it
generates for itself at https://${DOMAIN_HOST}/manage/ssh/ whose
public half you add on your code host with write access. Add people
from the same Manage section: the invitation link is copyable, so no
mail server is involved.
4. First backup written to $APP_DIR/backups: a database dump and a file
archive holding the data directory and the config. The archive
carries the VCS private key. They are on the same disk as the data,
which is not a backup. Copy them somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Lokalise.
- The reason to run this is also the part nobody can automate for you. Weblate's whole trick is that translations are commits in your own git repositories, and that only starts once you take the key it generates for itself under Manage, SSH keys and add its public half on your code host with write access. That is a browser session on an account you hold, it takes ten minutes, and until it is done you have a very good translation editor that pushes nowhere.
- No mail, and that is a real edge you should see clearly. This install closes registration and creates one administrator, so nothing needs to send an email for the core loop to work: you add translators by copying an invitation link out of the Manage screen. What you do not have is self-service signup and password reset. The day you want strangers to register themselves, you are configuring a mail server.
- You own a PostgreSQL and a data directory, and the data directory is the interesting one. It holds the cloned repositories, the translation memory, and the VCS private key, which upstream says plainly cannot carry a passphrase. Losing it means re-authorising Weblate on every code host it was connected to, so the archive in step 8 is not optional housekeeping.
- It wants 3 GB of RAM before it wants anything else. The first boot runs migrations and builds static assets, which is the hungriest moment of the install, and a box sized for a small web app dies there rather than later. On a laptop the number that matters is Docker Desktop's allocation, not the machine's.
- No managed translation vendors, no AI word allowance, no SSO, and nobody to call. Ordering professional translation inside the tool, the yearly machine-translation quota and the single sign-on are what the paid platforms are actually selling on top of the editor, and none of them arrives with this.
Where this came from
“The corresponding private SSH key can not currently have a password, so ensure it is well protected.”
- Upstream's own compose file runs three services and reaches its cache through REDIS_HOST, which is why this install pairs Weblate with a PostgreSQL and a Valkey rather than anything else. source
- The published image exposes port 8080, declares /app/data and /app/cache as volumes, runs as uid 1000, and carries a health check with a five-minute start period because migrations run in that window. source
- Upstream states 3 GB of RAM, 2 CPU cores and 1 GB of storage as the minimum for running Weblate, its database and a web server on a single host. source
- While WEBLATE_ADMIN_PASSWORD is set the admin account is adjusted to match it on every container start, and upstream warns that keeping a password in the configuration file is a risk. source
- An administrator can copy an invitation link straight out of the pending-invitations list, so a second translator can be added on an install that has no mail server at all. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Lokalise?
Not Lokalise itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Weblate. Continuous localization that lives in your git repositories: translators work in a browser, Weblate commits the result back. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 120 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Lokalise?
Weblate. Continuous localization that lives in your git repositories: translators work in a browser, Weblate commits the result back. The one that replaces the workflow rather than the screen. Weblate's translations are commits in your own git repositories: it clones the repo, translators work in a browser, and it pushes the changed files back on a schedule or into a pull request, which is the same shape as the Lokalise integration you are paying for and the reason two decades of open source projects and agencies run it. It is GPL-3.0 with no feature held back, it has the format coverage and the quality checks that make a review workflow mean something, and it carries a translation memory and a glossary you own. The trade is honest: nobody sells you professional translation inside it, the machine-translation engines are accounts you bring, and the git write access has to be granted by hand once before any of it commits anything. Also evaluated and ranked below it: Tolgee. The prompt on this page installs Weblate only. Weblate is GPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Lokalise?
3072 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. Weblate itself is free and GPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Lokalise Growth, $375/mo — $4,500 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Weblate install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Weblate on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Weblate on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Weblate only polls your repositories and pushes commits while this computer is awake, and nobody you invite can open http://localhost:8173, so on the local path it is a workbench for one person over their own repositories rather than a place a team meets. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.