Skip to content

Deploying OTB Works

What a customer does to get a working instance, from nothing to signed in.

This document was written and then followed literally on a real server (Ubuntu, x86_64, Docker 29.6.1, behind a reverse proxy). Every command below has been run. Where something did not work the first time, the instruction was corrected rather than worked around, and the correction is noted so you know it was found rather than assumed.

Budget 20 minutes, plus however long your licence takes to arrive.


  • A Linux host with Docker. Nothing else is installed on it.
  • Somewhere for the data to live. A single directory is enough.
  • A hostname people will use, and a decision about TLS (see step 2).
  • Your instance id will be generated on first boot. You quote it to OTB to get a licence, so the order is: install, read the id, request a licence, install the licence.

Works runs as one container with a SQLite database on a volume. There is no separate database server to provision, no message broker, and no cloud dependency. If you can run a container and give it a directory, you can run Works.


Normal:

Terminal window
docker pull ghcr.io/outsidethebox-io/otb-works:1.0.0

Air-gapped, or any site where the server cannot reach a registry. OTB gives you a .tar of the image. Copy it across however your change process allows, then:

Terminal window
docker load -i otb-works-1.0.0.tar
docker images | grep otb-works

Both paths end with the same image, and the rest of this document is identical.


This matters more than it looks. Works refuses to start if you give it an http:// address that is not localhost, because session cookies are marked Secure and a browser silently discards them on a plain-HTTP origin. Without that refusal you would get a sign-in page that accepts your password and then returns you to the sign-in page, for ever, with nothing in the log.

Pick one:

OTB_WORKS_TLS Use when What happens
off Something already terminates TLS for you: Traefik, nginx, an F5, a cloud load balancer, a Cloudflare tunnel The app listens on 4173 and your proxy does the rest
auto The server is reachable from the internet on 80 and 443, and DNS for your hostname points at it A certificate is obtained automatically. The app listens only on 127.0.0.1, so the plain port is not reachable at all
internal A LAN-only deployment with no public DNS A certificate from a local authority. Same loopback binding

If you already run a reverse proxy, use off. Setting OTB_TRUSTED_PROXIES implies off on its own, so an existing deployment cannot suddenly find this container competing for port 443.


Create a directory and a docker-compose.yml in it:

Terminal window
mkdir -p /srv/works && cd /srv/works
services:
works:
image: ghcr.io/outsidethebox-io/otb-works:1.0.0
container_name: otb-works
restart: unless-stopped
ports:
# Publish on ALL interfaces, or name one. If something else on this
# host reaches Works (a reverse proxy on another machine, say), bind
# the address that proxy uses:
# - "192.168.0.10:4173:4173"
# See the note under step 6 before you do: it changes what the health
# check has to ask for.
- "4173:4173"
environment:
OTB_PUBLIC_URL: https://works.example.com
OTB_WORKS_TLS: "off"
OTB_SESSION_SECRET: ${OTB_SESSION_SECRET:?set this in .env}
OTB_TRUSTED_PROXIES: "loopback,linklocal,uniquelocal"
volumes:
- works-data:/app/packages/server/data
volumes:
works-data:

Then the one secret, generated once and never changed:

Terminal window
cat > .env <<EOF
OTB_SESSION_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 .env

It is required and has no default. The session secret signs sign-in cookies; changing it signs everyone out.

OTB_NOTIFICATIONS_ENC_KEY used to be required here too. Since 1.1.0 it is not: the instance generates its own at-rest encryption keys on first boot and keeps them in instance-keys.json beside the database on the data volume. Setting the variable still works and still wins, see Installing and upgrading. The notifications key encrypts your mail provider’s credentials at rest, and without it the email step is unavailable, which means invitations, password resets and sign-in links cannot be sent.

Set OTB_PUBLIC_URL to the address people type in their browser, not the container’s address. Invitation links, password resets and single sign-on redirects are all built from it.


Terminal window
docker compose up -d
docker compose logs | tail -40

You are looking for two things in the log.

The instance id, which identifies this deployment for licensing:

instance identity: generated for this deployment - quote it to OTB when asking
for a licence (it is shown on the Licence screen)
instanceId: otbw-XXXX-XXXX-XXXX-XXXX

The setup code, a one-time code that lets the first administrator be created:

This instance has no administrator yet. Open the setup page and
paste the code below to create one:
https://works.example.com/setup
Setup code: <64 hex characters>

The code exists only in this log. It is never written to disk and stops working the moment an administrator exists. If you lose it, restart the container and a new one is printed.


Open /setup and work through it. Seven steps, and only two need anything you do not already have.

  1. Administrator: paste the setup code, then your name, email and a password of at least 12 characters.
  2. Sign in without a password: enrol a passkey, or skip. You can add one later from Account > Security.
  3. Licence: the screen shows your instance id with a copy button. This step is mandatory; see below.
  4. This programme: name, currency, locale, time zone, financial year, budget.
  5. Email: your mail provider. Needed before anyone can be invited.
  6. Single sign-on: OIDC or SAML, optional, and it tests the connection before it will enable it.
  7. What this instance may do: whether it may check OTB for new versions. There is no default: you answer it.

About the licence step. An unlicensed instance does nothing except let you create an administrator and install a licence. Every other route refuses. So the order is: start the container, copy the instance id, send it to OTB, and install the licence you get back. A licence is tied to that instance id, so it will not work on a different deployment.


Terminal window
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4173/api/health

200 means the application is up.

If that returns 000, check what the port is actually published on before assuming the app is broken:

Terminal window
docker port otb-works

4173/tcp -> 0.0.0.0:4173 answers on localhost. 4173/tcp -> 192.168.0.10:4173 does not, you bound it to one address, so ask for that address instead. This was found by following these instructions on a real server: the container was healthy and serving traffic through its proxy the whole time, while the documented check reported 000. A health check that fails on a working system is worse than none, because the next thing you do is restart something that was fine. Then, from a browser on the address in OTB_PUBLIC_URL, sign in as the administrator you just created.

If sign-in appears to succeed and returns you to the sign-in page, the cause is almost always the cookie: OTB_PUBLIC_URL does not match the address you are actually using, or something in front is terminating TLS without telling the app. Check step 2.


The whole instance is the data volume. There is no state anywhere else.

Terminal window
docker compose stop
docker run --rm -v works-data:/data -v "$PWD":/backup alpine \
tar czf /backup/works-backup-$(date +%Y%m%d).tar.gz -C /data .
docker compose start

Keep .env with it. Restoring the volume without the session secret signs everyone out; restoring without the notifications key makes the stored mail credentials unreadable.

The instance id lives inside the database, so a restored backup is the same deployment and keeps its licence. That is deliberate.


Covered in installing-and-upgrading.md. In short: most upgrades are applied by an administrator inside the product, with no terminal access at all. Some releases change the base image and say so, and those need somebody who can edit this compose file.


Symptom Cause
Container exits immediately, log names a variable OTB_SESSION_SECRET or OTB_PUBLIC_URL is missing. Both are deliberately required
Refuses to start, mentions http:// Step 2. A plain-HTTP origin cannot hold a Secure cookie
Sign-in loops back to sign-in Same cause, from the browser’s side
Everything answers 402 No licence, or a licence for a different instance id
Reads work, writes answer 402 The licence has lapsed. Reading and exporting still work; install a renewal
No setup code in the log An administrator already exists. Use the sign-in page