---
title: Deploy OpenLM LicOps
description: Run the OpenLM Router in LicOps mode as a Docker Compose stack on a Linux host in your own network.
product: OpenLM Platform
---

Deploy LicOps as a Docker Compose stack on a Linux host inside your network, reachable from your scheduler hosts. When the stack is running, connect it to OpenLM Platform in [Configure OpenLM LicOps](https://www.openlm.com/documentation/cloud/deployment-operations/licops/configuration).

To run LicOps on Kubernetes instead, see [Deploy on Kubernetes](https://www.openlm.com/documentation/cloud/deployment-operations/licops/deploy-kubernetes).

## Before you begin

Confirm these prerequisites:

- A Linux host that meets the [system requirements](https://www.openlm.com/documentation/cloud/deployment-operations/licops/system-requirements), with Docker Engine and the Docker Compose v2 plugin installed.
- Outbound access from the host to OpenLM Platform, and inbound access to port `8100` from your scheduler hosts.
- The encryption key you generated in [System requirements](https://www.openlm.com/documentation/cloud/deployment-operations/licops/system-requirements#encryption-key).

## Get the images

LicOps runs on the OpenLM Router images, which are published to the OpenLM public registry and pull anonymously. You need no `docker login` and no pull secret.

To pull the images, run these commands:

```bash
docker pull public.ecr.aws/r3q3q2f4/olm-routerapi:1.261006.0752
docker pull public.ecr.aws/r3q3q2f4/olm-routerui:1.261008.0932
```

Tags are build numbers in the form `1.<yymmdd>.<hhmm>`. There is no `latest` or other moving tag, so pin an explicit one and record which build you deployed. The two images are released separately, so their tags can differ.

### Find the newest build

To see every build that the registry holds for an image, list the image's tags. The registry issues the access token anonymously, so you need no account.

On Linux, with `curl` and `jq`, run these commands:

```bash
repo=olm-routerapi   # or olm-routerui
token=$(curl -s "https://public.ecr.aws/token/?service=public.ecr.aws&scope=repository:r3q3q2f4/$repo:pull" | jq -r .token)
curl -s -H "Authorization: Bearer $token" "https://public.ecr.aws/v2/r3q3q2f4/$repo/tags/list" | jq -r '(.tags // [.errors[0].message])[]' | sort
```

In PowerShell, run these commands:

```powershell
$repo = "olm-routerapi"   # or olm-routerui
$token = (Invoke-RestMethod "https://public.ecr.aws/token/?service=public.ecr.aws&scope=repository:r3q3q2f4/${repo}:pull").token
(Invoke-RestMethod "https://public.ecr.aws/v2/r3q3q2f4/$repo/tags/list" -Headers @{ Authorization = "Bearer $token" }).tags | Sort-Object
```

Run the commands once for each image. The tags sort in build order, so the last line is the newest build. If the output is `Rate exceeded`, or PowerShell reports `TOOMANYREQUESTS`, the registry is limiting anonymous requests: wait 5 seconds and run the commands again.

## Create the environment file

In a working directory on the host, for example `~/openlm-licops/`, create a file named `.env`:

```bash
# The MongoDB account the Compose file creates on the first start.
MONGO_USER=router
MONGO_PASSWORD=<your-generated-password>

# The key protecting the stored OpenLM Platform client secret. Paste the string that
# `openssl rand -base64 32` printed, exactly as it printed it.
ADMISSION_PLATFORM_ENCRYPTION_KEY=<your-generated-key>
```

Generate the MongoDB password with `openssl rand -hex 16`. The password becomes part of a connection string, so a password with characters such as `@`, `:`, or `/` breaks the connection from the Router to MongoDB. MongoDB creates the account only when its data volume is empty, so changing the password later does not change the existing account.

Never reuse an encryption key from a sample or a test stack. Generate your own, as described in [System requirements](https://www.openlm.com/documentation/cloud/deployment-operations/licops/system-requirements#encryption-key).

## Create the proxy file for the Router UI

To serve the Router UI, create `router-ui-proxy.conf` beside the `.env` file. The Router UI is served under the `/router-management/` path and expects a reverse proxy in front of it that strips that prefix and forwards API calls to the Router.

```nginx
server {
  listen 8101;

  # The Router UI, with the /router-management prefix stripped.
  location /router-management/ {
    proxy_pass http://127.0.0.1:8102/;
  }

  # API calls from the browser, forwarded to the Router.
  location /api/routerapi/ {
    proxy_pass http://127.0.0.1:8100/api/;
  }
}
```

## Create the Compose file

Create `docker-compose.yml` in the same directory:

```yaml
services:
  mongo:
    image: mongo:7
    ports:
      - "127.0.0.1:27017:27017"
    environment:
      - MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
      - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
    volumes:
      - mongo_data:/data/db
    restart: unless-stopped

  redis:
    image: redis:7
    command:
      - redis-server
      - --appendonly
      - "yes"
      - --appendfsync
      - everysec
      - --maxmemory-policy
      - noeviction
    ports:
      - "127.0.0.1:6379:6379"
    volumes:
      - redis_data:/data
    restart: unless-stopped

  routerapi:
    image: public.ecr.aws/r3q3q2f4/olm-routerapi:1.261006.0752
    depends_on: [mongo, redis]
    # Host networking, so the admission API and the LicOps listen port land on a
    # real host address that your scheduler hosts and compute nodes can reach.
    network_mode: host
    environment:
      - MONGODB__CONNECTIONSTRING=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@localhost:27017/?authSource=admin
      - REDIS__CONNECTIONSTRING=localhost:6379,abortConnect=false
      - ADMISSION_PLATFORM_ENCRYPTION_KEY=${ADMISSION_PLATFORM_ENCRYPTION_KEY}
      # Required. The Router takes its configuration and rules from the OpenLM Platform.
      - licOpsConfiguration__enabled=true
      - LICOPSCONFIGURATION_ENABLED=true
      # Required. Declares a LicOps Router. Fixed at the first start.
      - ROUTER_CONFIG_BOOTSTRAP_MODE=loadBalancer
      - ROUTER_CORRELATION_MODE=Ip
      - ROUTER_PROXY_PROTOCOL_ENABLED=false
      - ROUTER_TRUSTED_CIDR=127.0.0.1/32
      # Keep the background-job dashboard off.
      - hangfire__dashboard__enabled=false
    restart: unless-stopped

  routerui:
    image: public.ecr.aws/r3q3q2f4/olm-routerui:1.261008.0932
    ports:
      - "127.0.0.1:8102:8101"
    environment:
      - LB_FQDN=_
    restart: unless-stopped

  routerui-proxy:
    image: nginx:stable
    depends_on: [routerui]
    network_mode: host
    volumes:
      - ./router-ui-proxy.conf:/etc/nginx/conf.d/default.conf:ro
    restart: unless-stopped

volumes:
  mongo_data:
  redis_data:
```

Keep the settings of the Router exactly as shown:

- `licOpsConfiguration__enabled` and `LICOPSCONFIGURATION_ENABLED` make the Router take its configuration and admission rules from OpenLM Platform. Without them, nothing you define in **LicOps Configuration** reaches the Router.
- `ROUTER_CONFIG_BOOTSTRAP_MODE=loadBalancer` makes this Router a LicOps Router. When it connects, OpenLM Platform creates its starting LicOps configuration. Without this setting OpenLM Platform creates nothing, and the Router stays idle. The mode is fixed when the Router first starts, so `basic` here would give you a failover Router instead. To change it, deploy a new Router with its own empty MongoDB database, and stop the old one.
- `ROUTER_CORRELATION_MODE=Ip` and `ROUTER_PROXY_PROTOCOL_ENABLED=false` suit scheduler adapters and direct API calls. These variables are required. Keep them exactly as shown.

:::warning[Use the exact variable names shown]

`MONGODB__CONNECTIONSTRING` and `REDIS__CONNECTIONSTRING` take two underscores before `CONNECTIONSTRING`. The Router substitutes these names into its configuration at startup, so a misspelling is not merely ignored. The value lands empty and the container exits at boot with `Mongo ConnectionString cannot be null or empty.`

:::

The connection strings use `localhost` because the Router uses host networking. MongoDB and Redis publish their ports on `127.0.0.1` only, so the Router reaches them on the host while nothing outside the host can. If you run MongoDB and Redis on servers you already operate, change the two connection strings to point at them.

### Set the session timeouts

LicOps holds a seat for an admitted job until the job releases it, or until the session times out. Two timeouts decide when an unreleased seat goes back to the pool:

| Setting | Default | What it covers |
| --- | --- | --- |
| `admissionController__reconcile__admitTtlSeconds` | `900` (15 minutes) | How long an admitted job can wait before it starts. A job that starts later than this has lost its seat. |
| `admissionController__reconcile__dispatchTtlSeconds` | `7200` (2 hours) | How long a started job keeps its seat without a release. |

Set the admit timeout longer than jobs typically wait in your queue. A job that starts after its admit timeout has lost its seat, and LicOps might already have admitted another job for it. Set the dispatch timeout longer than your longest licensed job, so a running job keeps its session until it ends. To change a timeout, add the setting to the `routerapi` environment, for example:

```yaml
      - admissionController__reconcile__admitTtlSeconds=3600
```

The timeouts are a safety net, not the normal path. A job that ends normally releases its seat at once.

## Start the stack

To start the stack and follow the Router log, run these commands:

```bash
docker compose up -d
docker compose logs -f routerapi
```

On the first start, the Router creates its identity and logs that it takes its configuration from OpenLM Platform:

```text
Bootstrap seed skipped: the Router is service-managed (LicOps configuration enabled); identity RouterId <guid> established.
Router configuration shape: service-managed; ...
```

In this setup you change the configuration and the rules in OpenLM Platform, not on the Router.

To confirm the Router is answering, run this command:

```bash
curl http://localhost:8100/api/v1/status
```

```json
{
  "status": "unconfigured",
  "configurationManagement": {
    "shape": "serviceManaged",
    "routerId": "7c1e5b2a-3f4d-4e8a-9b6c-2d0f8a1e4c37",
    "lastSync": {
      "lastSuccessfulSync": null,
      "lastFailure": "No Platform connection is configured, so the Router has no address for the LicOps Configuration service"
    }
  }
}
```

This is the correct result for a new Router. It is running but is not connected to OpenLM Platform, so it has no configuration and no pools. The configuration comes from OpenLM Platform, so connect the Router as described in [Configure OpenLM LicOps](https://www.openlm.com/documentation/cloud/deployment-operations/licops/configuration).

The `routerId` is created once and stored in MongoDB. It is how OpenLM Platform tells this Router apart from others. Wiping the database creates a new one, which OpenLM Platform sees as a different Router.

The interactive API reference is at `http://<licops-host>:8100/swagger`.

## Verify the deployment

To confirm the containers are running and the Router UI loads, run these commands:

```bash
docker compose ps
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8101/router-management/
```

The second command prints `200`. Open `http://<licops-host>:8101/router-management/` in a browser. The Router UI opens on **Platform Connection**, with a notice that no OpenLM Platform connection is configured. This is expected until you complete the next step.

The readiness endpoints of the Router, `/readyz` and `/health/ready`, answer `503` until the Router has collected its configuration from OpenLM Platform. On a new installation this is expected, not a fault.

Continue with [Configure OpenLM LicOps](https://www.openlm.com/documentation/cloud/deployment-operations/licops/configuration) to connect LicOps to OpenLM Platform.

## Upgrade

Pin the new tag of each Router image in `docker-compose.yml`. To list the available builds, see [Find the newest build](#find-the-newest-build). Then run these commands to recreate the containers:

```bash
docker compose pull routerapi routerui
docker compose up -d routerapi routerui
```

The configuration and rules live in OpenLM Platform, and the open sessions live in Redis, so they survive the replacement. While the Router restarts, admission requests fail: the scheduler adapters then apply the fail mode you chose, described in [Connect workloads](https://www.openlm.com/documentation/cloud/deployment-operations/licops/integrations/index#decide-what-happens-when-licops-is-unreachable). For about 10 seconds after the restart, until the Router has caught up with the seat counts in OpenLM Platform, admit can answer `Deny` with the reason `StaleSnapshot`. The Slurm and LSF adapters treat that as a denial and reject the submission, so schedule upgrades for a quiet period.
