Docs
Deploy with Docker
Run Localization OS in a container, in production: the default SQLite setup, an optional Postgres backend, TLS via a reverse proxy, the secrets you manage, and the single data volume you back up.
Quick start with Docker (SQLite, default)#
SQLite is the zero-config default. No database server, no extra services.
docker compose up -d --build
The app is now on http://127.0.0.1:8000 (localhost only; front it with a proxy for public traffic). Data persists in the loc_data named volume.
On first boot the login wall is on and no accounts exist yet. Your first visit redirects to a first-run setup wizard where you create the admin account (12-character minimum password). There are no seeded default passwords: the container ships closed, and the first person to reach it becomes the admin. Put it behind a proxy, or bind it to localhost, before first boot so nobody else can claim setup ahead of you.
What happens on that first up:
- The image builds in two stages: a builder compiles dependencies, and the runtime image ships only what it needs, running as a non-root user.
- The server starts directly, without the interactive developer auto-reload mode used locally.
- An environment variable points the database, uploads, and keyfiles at the mounted
loc_datavolume, so they survive restarts and image upgrades. - A second environment variable declares this a real server install, which turns the login wall on and starts the background scheduler without seeding any accounts. There are no default admin credentials.
The port is published on 127.0.0.1:8000 only. That is deliberate: put a reverse proxy in front for anything reachable from the network or the internet.
docker compose logs -f app # tail app logs
docker compose ps # service + health status
docker compose down # stop (keeps the volume/data)
docker compose down -v # stop AND DELETE the data volume (destructive)
docker compose pull && docker compose up -d --build # upgrade in place
Health endpoints (also used by the image's own healthcheck):
GET /health: liveness, no database work.GET /ready: readiness, checks the database connection.
Postgres mode (optional)#
SQLite is fine for a single instance. Choose Postgres for larger installs, external backups, or when you want the database off the app host. On Postgres, the schema is managed by Alembic migrations, applied automatically on boot.
With the bundled database service
Create a .env next to docker-compose.yml:
POSTGRES_USER=locos_app
POSTGRES_PASSWORD=change-me-to-something-strong
POSTGRES_DB=locos_app
# Point the app at the bundled db service.
# NOTE the driver scheme: postgresql+psycopg:// (psycopg3).
DATABASE_URL=postgresql+psycopg://locos_app:change-me-to-something-strong@db:5432/locos_app
Then start with the Postgres profile:
docker compose --profile postgres up -d --build
The database service comes up first, then the app sees a Postgres DATABASE_URL and runs migrations before starting. Postgres data persists in its own volume; the loc_data volume still holds uploads and keyfiles.
With an external or managed Postgres
If your Postgres lives elsewhere (a managed database service, an existing server), skip the bundled database service entirely: just set DATABASE_URL and run the default app service.
DATABASE_URL=postgresql+psycopg://user:pass@your-db-host:5432/locos_app
docker compose up -d --build # no --profile postgres needed
The app still runs migrations on boot because the URL is a Postgres URL. To run migrations manually instead, as a controlled release step, you can exec into the container:
docker compose run --rm app alembic upgrade head
TLS and reverse proxy#
Do not expose the app directly on the public internet. Terminate TLS at a reverse proxy and forward to the app over the internal network. Two ready samples ship in the repository's deploy/ directory:
- A Caddy sample (recommended): automatic HTTPS via Let's Encrypt.
- An nginx sample: for shops already running nginx, bring your own certs.
Caddy (auto-HTTPS), via the compose proxy profile
- Edit the Caddy sample: replace the placeholder domain (e.g.
tms.example.com) with your real domain, and the placeholder contact address with a real ACME contact email. The domain's DNS must resolve to this host, and ports 80/443 must be reachable for the ACME challenge. - Start with the proxy profile:
docker compose --profile proxy up -d --build # combine with Postgres if you use it: docker compose --profile postgres --profile proxy up -d --build
Caddy publishes 80/443, obtains and renews certificates automatically, and reverse-proxies to the app on the internal network.
nginx
Use the nginx sample as a vhost. Point ssl_certificate / ssl_certificate_key at certs you manage yourself (certbot, your own CA).
Proxy notes that matter here
- Only the proxy should be internet-exposed. The app service is published on
127.0.0.1:8000for local admin; a bundled Postgres service should never be published to the network. - Preserve the
Hostheader end to end. The app's cross-site protection is same-origin based, so a proxy that rewritesHostcan trigger unexpected access errors. Both samples preserve it. - SSE endpoints (live translate, job progress) need response buffering off and a long read timeout. The nginx sample disables buffering and extends the read timeout; Caddy streams these fine by default.
- Upload size. Both proxy samples cap request bodies at roughly 100 MB to match the app's own upload limit; raise both together if you raise the app's limit. That ceiling is a limit, not a promise: a large translation-memory file made of many small units can cost tens of times its file size in processing time and memory, so on a small machine an unusually shaped file can still be slow or memory-heavy even under the size cap.
- Secure session cookies behind a proxy. TLS terminates at your proxy, so the app cannot see the scheme on its own and needs to be told. Set the app's secure-cookies option on explicitly (or set its public base URL to your
https://address) when you run it behind a reverse proxy, so session cookies get the browser'sSecureflag. - Framing. Responses ship with an anti-framing header by default, so the app cannot be embedded in another site's page. If you intentionally embed the web UI in another origin, there is a setting to allow a specific, space-separated list of origins instead.
- An IP allowlist for the back office, behind a proxy. The allowlist matches the address the app sees on the connection. Behind a reverse proxy, that is the proxy's own address, not the original client's, so listing the proxy in the allowlist would admit every client the proxy forwards for. Use this control only where the app sees the real client address directly.
Required secrets#
The app needs two secrets. In production, set them explicitly rather than relying on the auto-generated keyfiles, so you control them and can reproduce the environment.
| Secret | Environment variable | What it protects |
|---|---|---|
| Session secret | SESSION_SECRET | Signs session cookies; also derives the audit-log signing key. |
| Encryption key | ENCRYPTION_KEY | Encrypts uploads and stored connector credentials at rest. |
Losing the encryption key is unrecoverable. If you rotate or lose it, every encrypted upload and every stored connector token becomes permanently undecryptable. Back this key up somewhere safe, separate from the data volume. Changing the session secret is milder: it logs everyone out and invalidates the pre-rotation portion of the audit trail's own verification.
Two ways to provide the secrets
A. Via environment (recommended for production). Put them in a .env next to the compose file, or your orchestrator's secret store. Generate strong values:
# session secret (any high-entropy string; hex shown here)
python -c "import secrets; print(secrets.token_hex(32))"
# encryption key (must be a valid Fernet key)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
SESSION_SECRET=<paste the token_hex value>
ENCRYPTION_KEY=<paste the Fernet key>
Compose passes these into the app service. When set via environment, the app does not write keyfiles.
B. Via keyfiles in the data volume (zero-config default). If you set neither environment variable, on first boot the app generates its own key material inside the data volume and reuses it thereafter. This "just works" for a single instance, but the secrets then live only in the volume, so your backup of that volume is your only backup of the keys, and multiple instances cannot share them. Prefer option A for anything beyond a single box.
Provider API keys are optional; the app runs offline on a deterministic mock engine with none set. They are passed the same way, as host environment variables.
The data volume#
Everything stateful lives in the single named volume mounted into the container:
- the SQLite database (SQLite mode only),
- uploaded source documents, which may be encrypted at rest,
- the two secret keyfiles, only when they are not set via environment,
- rulebooks, prompt files, and other runtime artifacts.
Back this volume up regularly. It contains your documents and, in the default keyfile mode, your only copy of the encryption key. Losing it loses your data; losing the key inside it loses the ability to decrypt uploads and connector tokens.
# snapshot the whole volume to a tarball on the host
docker run --rm -v loc_data:/data -v "$PWD":/backup alpine \
tar czf /backup/loc_data-$(date +%F).tar.gz -C /data .
In Postgres mode, also back up the database itself in addition to the data volume, which still holds uploads and keyfiles. Full backup and restore procedures for both backends live on the backup and restore page.
Runtime notes#
- No developer auto-reload in the container. The image runs the production server directly; the auto-reload mode used for local development is not the container entrypoint, because the reloader is not meant for production.
- Binds all interfaces inside the container, but sits behind the proxy. Inside the container the app listens broadly so the proxy can reach it; the container's port is published only on
127.0.0.1on the host. Public traffic goes through the proxy. - Single-process job engine. The background job queue and scheduler run in-process. Run a single app replica; scaling to multiple instances needs a shared broker, which is not built yet. A restart re-queues jobs that had not started and marks mid-run jobs as interrupted, visibly, never silently lost.
- Non-root by default. The container runs as an unprivileged user; the data volume is owned by it on first create.
- Postgres migrations run on boot. With a Postgres
DATABASE_URL, migrations run before the server starts. SQLite skips this; the app manages its own schema. - Structured JSON logging (opt-in). An environment variable makes the app and its access/error logs emit one JSON object per line, for a log shipper. It is off by default, so behavior is unchanged unless you opt in.
- Reproducible installs. A curated set of direct dependencies is the default install; a full pinned lockfile is also available for byte-stable, fully reproducible builds.
Headless first-admin bootstrap (optional)#
A container or CI install can seed its first admin without the interactive setup wizard, using the headless CLI. This command is idempotent and off by default: with the environment variables unset, boot behaves exactly as before.
# before the app is otherwise used: create the admin from env, mint an API key once
LOC_TMS_ADMIN_EMAIL=admin@example.com \
LOC_TMS_ADMIN_PASSWORD='a-strong-passphrase' \
docker compose exec app python -m app.cli init --from-env --issue-key
- Enforces the 12-character password minimum and turns on the login wall.
- Idempotent: if an account already exists it does nothing, so it is safe to run on every boot.
- The key is printed once. Capture it from the command's output; it is never recoverable afterward.
The same CLI also runs offline database backups, applies configuration, and verifies the audit trail. See the CLI reference.