Self-hosted install

Updated October 2026

Describes how to run the Dousen server (DousenCore) on your own infrastructure with Docker Compose, including configuration, licensing, directory sign-in, and troubleshooting.

One container runs the API and web portal, and one runs MariaDB. DousenDesktop and the Unreal plugin talk to the API over HTTP and JSON on port 8080. Dousen’s cloud control plane, DousenControl, issues your signed license and receives your instance’s license heartbeat. You don’t run it yourself.

The deployment bundle in deploy/self-hosted/ contains docker-compose.yml, the install.sh helper, an annotated .env.example, and SELF_HOSTING.md with operational notes. Where those files differ from this page, follow this page. If you don’t have the bundle, email [email protected].

install.sh is the fastest way to install. It generates the database and JWT secrets, asks for your license key, Perforce details, and whether to expose Dousen on the local network, writes a chmod 600 .env in ~/.dousen (or DOUSEN_INSTALL_DIR), and starts the stack. At the public-key prompt, enter the path to the PEM file (a leading ~/ works) or paste the whole PEM. Leave it blank to add the key to .env later, as described under Install. To review every setting before anything runs, follow the Install steps instead.

Requirements

Install and verify a studio server

  1. Run the install script or follow the Install steps. Keep the .env file private, because it holds the database credentials and secrets.
  2. Start the Compose services and read the logs. The database should initialize, with no JWT secret error and no Core is locked license message.
  3. Create the first admin, open /portal/ at the studio’s HTTPS hostname, and sign in. Create one test project, then invite one test artist.
  4. On an artist workstation, sign in to DousenDesktop against that hostname. Set up the artist’s own Jira and Perforce access in Desktop, which is separate from the server-side integrations.
  5. Install the matching DousenUE plugin, configure the test project, and run a complete test publish.
  6. Before rollout, back up the database, the persistent data, and the deployment configuration. Restore the backup to a test instance, then sign in to the portal and DousenDesktop on it as in steps 3 and 4.

The server is ready when portal sign-in, project configuration, artist sign-in, and a saved Unreal import all work through the studio URL. A healthy container doesn’t prove this, because /health keeps answering while Core is locked.

Install

Run every docker compose command on this page from the directory that holds docker-compose.yml and .env (~/.dousen if you used install.sh).

  1. Copy the bundle and create your env file.

    Copy deploy/self-hosted/ to the host and copy .env.example to .env. Every setting on this page goes in this file. If you ran install.sh, it already wrote ~/.dousen/.env, so skip this step.

  2. Fill in the required values.

    Set the database passwords, DOUSEN_JWT_SECRET, and your license key and public key. Put the public key in double quotes, either on several lines or on one line with \n between the lines. Replace the placeholder secrets with generated ones:

    SHELLopenssl rand -hex 32

    DOUSEN_JWT_SECRET must be at least 32 bytes. If it’s shorter, DousenCore exits at startup with JWT secret must be at least 32 bytes, got N. Length is the only check, so the 33-character placeholder in .env.example passes. Replace it with the 64-character output of openssl rand -hex 32.

  3. Start the stack.
    SHELLdocker compose up -d

    The API container waits until MariaDB reports healthy, runs its database migrations, and starts on port 8080. Migrations run on every start, so there’s no separate migration step.

  4. Check that the server answers.
    SHELLcurl http://localhost:8080/health

    The admin portal is at http://<server>:8080/portal/, and / and /portal both redirect there. Next, create the first admin.

Create the first admin

A new instance has no users, and the portal has no sign-up page, so create the first admin with dousen-cli, which is in the container image. Set your license first, because a locked instance refuses portal sign-in.

SHELLdocker compose exec dousen-api sh -c 'dousen-cli --database-url "$DOUSEN_DATABASE_URL" \
  create-user --email [email protected] --display-name "Studio Admin" --role admin \
  --password "choose-a-strong-password"'

The command creates the account under a placeholder tenant. DousenCore moves it to your licensed tenant when it starts, so restart the API container afterward:

SHELLdocker compose restart dousen-api

Open /portal/ and sign in with that email and password. Because the password was typed on the command line, change it on your account page (/portal/account). From then on, add people with Invite User (see User and auth management).

With directory sign-in (DOUSEN_AUTH_MODE=ldap), you can skip the command. The first successful sign-in by a member of DOUSEN_LDAP_ADMIN_GROUP creates an Admin account, provided that Dousen has a user search base (see Directory sign-in).

Configuration reference

All server settings are environment variables in .env. The shipped docker-compose.yml passes only the variables listed in the environment: block of the dousen-api service, so any other variable in .env has no effect. The tables below mark the settings that block lacks. To use one, add it in the same form as the existing entries (VAR: ${VAR:-default}).

Required

VariablePurpose
MARIADB_ROOT_PASSWORDRoot password for the bundled MariaDB.
MARIADB_USER / MARIADB_PASSWORD The service account the API uses (default user dousen).
DOUSEN_JWT_SECRET Secret for signing session tokens, at least 32 bytes. See Install.
DOUSEN_LICENSE_KEYYour license token, pasted exactly as issued.
DOUSEN_LICENSE_PUBLIC_KEY_PEM The public key issued with the license. DousenCore uses it to verify the license offline. If the value isn’t a valid PEM public key, Core starts locked and logs the reason. See Licensing.

Optional

VariablePurpose
DOUSEN_CONTROL_PLANE_URL Where the license heartbeat goes. The default is https://control.dousen.io. Set it to empty to turn the heartbeat off. A heartbeat that fails because of the network is logged and retried, so offline networks work. See Licensing.
DOUSEN_P4PORT / DOUSEN_P4USER / DOUSEN_P4CLIENT / DOUSEN_P4PASSWD The server’s own Perforce connection. See Perforce for what uses it. Artists' Perforce users are set in DousenDesktop.
DOUSEN_WEBHOOK_SECRETShared secret for the Jira webhook at /webhooks/jira.
DOUSEN_BIND_HOST / DOUSEN_REST_PORT Where the API port is published on the host. The default is 127.0.0.1:8080, which is loopback only. Put a reverse proxy in front (recommended, and where you terminate TLS), or set DOUSEN_BIND_HOST=0.0.0.0 to expose the port directly on a trusted LAN. If artists can’t reach the server, check this first.
DOUSEN_VERSION Image tag to run. The default is latest. Pin a version to control upgrades.
RUST_LOGLog level. The default is info.
DOUSEN_SECURE_COOKIES Sets the Secure flag on session cookies. The default is true. Not in the default compose file. If you test over plain HTTP without a reverse proxy, add it and set it to false. Otherwise the browser drops the Secure cookie, and login appears to succeed but returns to the sign-in page.
DOUSEN_CORS_ORIGINS Comma-separated allowed origins. The default is same-origin only, which suits the single-container setup. Set it only if you serve the portal UI from a different origin than the API. Not in the default compose file.

Email: invites and password resets (SMTP)

DousenCore uses SMTP to email invites (Invite User) and password-reset links. The default compose file doesn’t pass these variables through, so add them to the environment: block.

VariablePurpose
DOUSEN_PUBLIC_URL Public base URL used to build the links in emails ({public_url}/portal/reset-password?token=…). The default is http://localhost:8080. Set it to your real reverse-proxy URL.
DOUSEN_SMTP_HOSTSMTP server. Empty (the default) turns email sending off.
DOUSEN_SMTP_PORTThe default is 587 (STARTTLS).
DOUSEN_SMTP_USER / DOUSEN_SMTP_PASSWORDSMTP login credentials.
DOUSEN_SMTP_FROMFrom address. The default is [email protected].

With DOUSEN_SMTP_HOST unset, Invite User still creates the account and Send reset link still creates the reset token, but no email goes out. The portal then shows "… was created, but SMTP isn't configured on this instance — no email was sent." after an invite and "SMTP not configured — no email sent. The link is logged only at debug level (RUST_LOG=info,dousen_api::email=debug)" after a reset. The invite message also says the link is logged only at debug level. A malformed DOUSEN_SMTP_HOST logs a warning at startup and behaves the same way.

Core logs the link only at the debug level. To get it:

  1. Set RUST_LOG=info,dousen_api::email=debug in .env and run docker compose up -d.
  2. Click Resend invite in the user’s row, or Send reset link for an account that already has a password. Then search the log for the email (SMTP disabled) line:
    SHELLdocker compose logs dousen-api | grep "email (SMTP disabled)"
  3. Give the link to the user yourself.
  4. Set RUST_LOG back to info, because that log line contains the token.

Once SMTP is configured, the same actions send the email over STARTTLS, and the portal reports "Invitation sent to …" or "Reset link sent".

Directory sign-in (LDAP / Active Directory)

By default, users sign in with passwords stored on the instance (DOUSEN_AUTH_MODE=local). To use your directory instead, set DOUSEN_AUTH_MODE=ldap. DousenCore then creates an account on a user’s first successful sign-in: members of the admin group become Admins and everyone else becomes an Artist. After that, the role you set in Dousen wins (see Which role wins).

VariablePurpose
DOUSEN_LDAP_URLDirectory server, for example ldaps://ad.studio.lan.
DOUSEN_LDAP_BIND_DN_TEMPLATE Template for the bind DN of the signing-in user. For example, {username}@studio.local (AD UPN) or uid={username},ou=people,dc=studio,dc=local (OpenLDAP).
DOUSEN_LDAP_USER_SEARCH_BASE / DOUSEN_LDAP_USER_FILTER Where and how to look users up, for example the base OU=Staff,DC=studio,DC=local and the filter (sAMAccountName={username}). If you leave the base unset, Dousen derives it from DOUSEN_LDAP_BIND_DN_TEMPLATE: the parent DN of a DN-style template, or dc=x,dc=y from {username}@x.y. If you leave the filter unset or empty, it uses the first RDN of a DN-style template, such as (uid={username}), and otherwise (sAMAccountName={username}). At startup, Dousen logs a warning if it can’t derive a base. Without a working base and filter, Dousen can’t read the user’s email or groups: everyone becomes an Artist, the admin group is ignored, and an invited account doesn’t attach to that user’s first sign-in.
DOUSEN_LDAP_ADMIN_GROUP Directory group (a CN or a full DN) whose members become Dousen admins when their account is first created.
DOUSEN_LDAP_START_TLS Turns on StartTLS. The default is false. Prefer an ldaps:// URL instead.

Dousen binds as the signing-in user, so you don’t need a service account, and it always rejects empty passwords. If the directory returns no mail attribute for a user, the login name becomes the Dousen account identifier.

Connect DousenDesktop and Unreal

Check that /health answers before you give DousenDesktop to artists. Then, on each workstation:

  1. Install DousenDesktop and choose Self-hosted on first launch.

    Onboarding asks whether the studio is Dousen Cloud or Self-hosted. Choose Self-hosted and enter the Server URL: the address your reverse proxy exposes, or, on a trusted LAN, the host’s address on port 8080. Click Test Connection and continue after it shows ✓ Connected. The test only checks /health, which also answers while Core is locked, and setup doesn’t require it to pass. Then finish setup.

  2. Sign in.

    With DOUSEN_AUTH_MODE=local, artists sign in with the email and password they set from their invite email (see User and auth management). With DOUSEN_AUTH_MODE=ldap, they sign in with their directory credentials.

To change the address later, edit the server URL on the Connection tab of DousenDesktop’s Settings dialog.

The Unreal plugin has its own server settings in Config/DefaultDousenUE.ini. Set ServerScheme and ServerHost to the same server as the Server URL in Desktop. The plugin takes its DousenCore access token from Desktop’s credential file (core.credentials.json, next to bridge.token), and uses it only when the server URL in that file matches. Artists open Unreal themselves while DousenDesktop is running and signed in. For ProjectId, the bridge port, and environment overrides, see Configure the plugin.

The Unreal plugin refuses to contact DousenCore over plain HTTP unless the host is loopback, and its ServerScheme defaults to https. If you expose the port on a LAN without TLS, the Output Log shows Refusing to contact DousenCore over cleartext HTTP on a non-loopback host. Put the instance behind a reverse proxy with TLS and use an https address.

Licensing

The license is an Ed25519-signed JWT (DOUSEN_LICENSE_KEY) plus the public key issued with it (DOUSEN_LICENSE_PUBLIC_KEY_PEM). The token encodes your tier, seat count, features, and expiry. DousenCore verifies it offline against the public key, so it works on an air-gapped network.

DousenCore fails closed. Without a valid license it starts locked, and the only bypass is the development setting below. At startup:

License stateResultLog line
No public key Locked DOUSEN_LICENSE_PUBLIC_KEY_PEM not set — Core cannot verify any license and is locked (fail closed)
Public key set, but no license key and no saved license Locked DOUSEN_LICENSE_KEY not set and no persisted license found — Core is locked (fail closed)
Public key set, but not a valid PEM Locked DOUSEN_LICENSE_PUBLIC_KEY_PEM is not a valid PEM public key (…) — Core cannot verify any license and is locked (fail closed), followed by the parse error
Valid, unexpired license Starts normally license valid, with the tier, seat count, and days until expiry
Expired, within the 14-day grace period Keeps running. Artists aren’t blocked. LICENSE EXPIRED — operating in grace period. Renew at https://dousen.io/billing
Expired past the grace period, or a token that fails verification Locked license validation failed — Core is locked (fail closed), followed by the reason

A locked instance keeps running, but the portal, the /api/* endpoints, and the Jira webhook return HTTP 403 with "code": "unlicensed" and a message that starts with Dousen Core is unlicensed:. Artists can’t sign in or refresh their sessions. /health and /metrics still answer.

The instance can also lock while it’s running:

While locked, Core sends a heartbeat every hour. If you fix the cause on the control-plane side, for example by paying or getting the license unsuspended, the instance can unlock within the hour. After a revoked or cancelled reply, Core remembers the rejection across restarts, so only a newly issued license unlocks it. A heartbeat can also deliver a renewed license, which Core saves and uses on later starts.

To turn the heartbeat off, set DOUSEN_CONTROL_PLANE_URL= to an empty value in .env. With the heartbeat off, a revoked license keeps working until the license token expires and the 14-day grace period ends.

For local development only, DOUSEN_DEV_UNLICENSED=true turns on every feature with unlimited seats and no license. Core logs DOUSEN_DEV_UNLICENSED=true — running with an all-features, unlimited-seat license at startup. Not in the default compose file. Never set it in a production configuration.

The admin portal doesn’t show license or grace-period status. To get warning before the grace period ends, watch docker compose logs dousen-api for license valid, LICENSE EXPIRED, and Core is locked or Core is now locked, or have your log shipper alert on them.

To renew, replace DOUSEN_LICENSE_KEY in .env with the renewed token and run docker compose up -d.

Operational notes

Troubleshooting

SymptomCheck
The log shows Core is locked (fail closed) about a missing license key or public key Set DOUSEN_LICENSE_KEY and DOUSEN_LICENSE_PUBLIC_KEY_PEM in .env and restart. For local development only, DOUSEN_DEV_UNLICENSED=true bypasses the check.
Requests return 403 with "code": "unlicensed", but the container is healthy Core is locked. Read the reason after Dousen Core is unlicensed: in the response and the matching locked line in docker compose logs dousen-api. See Licensing.
The API container restarts repeatedly and the log shows JWT secret must be at least 32 bytes Set DOUSEN_JWT_SECRET to a value of at least 32 bytes, for example the output of openssl rand -hex 32.
Requests return 403 and the log shows DOUSEN_LICENSE_PUBLIC_KEY_PEM is not a valid PEM public key Core is locked because it can’t read the key. Paste the key again exactly as issued, inside double quotes. See Install.
API container won’t come up, or database connection errors Run docker compose ps. The mariadb service should show healthy before dousen-api starts against it.
Login appears to succeed, then returns to the login page You’re testing over plain HTTP without a reverse proxy, and DOUSEN_SECURE_COOKIES defaults to true, so the browser drops the Secure cookie. Add the variable to the compose file’s environment: block and set it to false for local testing, or put TLS in front of the server.
DousenDesktop or the Unreal plugin can’t reach the server Check that port 8080 (or your reverse proxy’s port) is reachable from the workstation and isn’t blocked by the host firewall. Without a proxy, check that DOUSEN_BIND_HOST isn’t still loopback-only. The Unreal plugin also needs an https address for any host that isn’t loopback.
Invite or reset emails never arrive Check the SMTP settings under Email, and read docker compose logs dousen-api for a send failure.
You need the current logsRun docker compose logs -f dousen-api.