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.

# once: Compose requires POSTGRES_PASSWORD in your private .env, even on SQLite
echo "POSTGRES_PASSWORD=$(python -c 'import secrets; print(secrets.token_hex(24))')" >> .env

# label the image with the release you are running
export RELEASE="$(git describe --tags --always --dirty)"
export RELEASE_COMMIT="$(git rev-parse HEAD)"

docker compose up -d --build

Without the two release variables the image still builds, but the health endpoint and the CLI's version command report a placeholder version instead of your release. For a production server, scan and pin the images before they serve; see Image scanning.

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.

Note

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 setup wizard asks for a one-time setup token that the server prints in its startup log and keeps in the data folder (or that you set yourself in the environment, at least 16 characters), so only someone with access to the server can claim it. Still put it behind a proxy, or bind it to localhost, before first boot.

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_data volume, 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)
# to upgrade, follow the checkpoint procedure under Upgrades below

Health endpoints (also used by the image's own healthcheck):

  • GET /health: liveness, no database work.
  • GET /ready: readiness, checks the database connection. The image's healthcheck uses this one.
  • GET /admin/health: operational details, for a signed-in administrator only.

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

  1. 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.
  2. 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:8000 for local admin; a bundled Postgres service should never be published to the network.
  • Preserve the Host header end to end. The app's cross-site protection is same-origin based, so a proxy that rewrites Host can 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's Secure flag.
  • 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.

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.

SecretEnvironment variableWhat it protects
Session secretSESSION_SECRETSigns session cookies; also derives the audit-log signing key.
Encryption keyENCRYPTION_KEYEncrypts uploads and stored connector credentials at rest.
Warning

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.1 on 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.

Image scanning before first start#

The deploy guide in the repository includes an image scan gate for every first launch, upgrade and rollback: build the app image without starting it, pull every other enabled service image (the proxy, Postgres), scan each one with Trivy using the shipped scan script, and pin every service to the exact local image ID that passed. Only then start, with docker compose up -d --no-build --pull never, so a moved tag can never change what runs. A finding stops the procedure unless the operator records a written risk acceptance for that exact image ID. Base images in the compose file are pinned by digest.

Upgrades#

Upgrade from a clean checkout of the release you approved, with the same project name, secrets, profiles and volumes. Keep the old image and record its digest so you can roll back.

  1. Block traffic, stop the app and every other writer.
  2. Take a checkpoint with the old image and verify it, then copy it off the data volume:
    docker compose stop app
    docker compose run --rm --no-deps --entrypoint python app -m app.cli db backup-full /data/checkpoints/pre-upgrade.tar.gz
    docker compose run --rm --no-deps --entrypoint python app -m app.cli db verify-backup /data/checkpoints/pre-upgrade.tar.gz
  3. Build, scan and pin the new image, run the migration checks, then start.
  4. To roll back, restore the checkpoint and start the recorded old image, scanned the same way.

Nightly backups#

The documented single-node targets are a recovery point of 24 hours (one verified full backup a night) and a recovery time of one hour. A shipped script runs the nightly job: it stops the app, writes a full backup to a mounted, encrypted off-host folder, verifies the stored copy, and restarts the app on success or failure. It keeps the newest verified archives (14 by default) and prunes older ones only after the night's archive verifies, so a failed run never deletes a good copy. Point its alert command at your paging tool so any failure is reported. The app is offline for the length of the backup; factor that into your schedule.

Limits and file-format settings#

Every upload stops at 50,000 segments per document and 100,000 characters per line or paragraph, an image at 8,000 pixels a side or 25 megapixels in total, and a connector run at 50 million characters of source content. Each limit is an environment setting you can raise on a larger host. A few format behaviours are settings too, such as MediaWiki template parameters and Fluent terms; see File formats.

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.