Self-hosted install
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
- A Linux host (Ubuntu 22.04+ or Debian 12+) with Docker 24+, Docker Compose v2, and at least 2 GB RAM (4 GB recommended).
- Persistent storage, and an HTTPS hostname for the studio.
- A Dousen license: the token and its public key, issued for your studio. Self-serve issuance isn’t live yet, so email [email protected] to get one. Without a license, DousenCore starts locked (see Licensing).
- Optionally, a Perforce server, a Jira Cloud site, an LDAP or Active Directory directory, and an SMTP server for invite and password-reset emails.
Install and verify a studio server
- Run the install script or follow the Install steps. Keep the
.envfile private, because it holds the database credentials and secrets. - Start the Compose services and read the logs. The database should initialize, with no JWT secret error and no
Core is lockedlicense message. - Create the first admin, open
/portal/at the studio’s HTTPS hostname, and sign in. Create one test project, then invite one test artist. - 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.
- Install the matching DousenUE plugin, configure the test project, and run a complete test publish.
- 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).
-
Copy the bundle and create your env file.
Copy
deploy/self-hosted/to the host and copy.env.exampleto.env. Every setting on this page goes in this file. If you raninstall.sh, it already wrote~/.dousen/.env, so skip this step. -
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\nbetween the lines. Replace the placeholder secrets with generated ones:SHELL
openssl rand -hex 32DOUSEN_JWT_SECRETmust be at least 32 bytes. If it’s shorter, DousenCore exits at startup withJWT secret must be at least 32 bytes, got N. Length is the only check, so the 33-character placeholder in.env.examplepasses. Replace it with the 64-character output ofopenssl rand -hex 32. -
Start the stack.
SHELL
docker compose up -dThe 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.
-
Check that the server answers.
SHELL
curl http://localhost:8080/healthThe admin portal is at
http://<server>:8080/portal/, and/and/portalboth 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
| Variable | Purpose |
|---|---|
MARIADB_ROOT_PASSWORD | Root 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_KEY | Your 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
| Variable | Purpose |
|---|---|
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_SECRET | Shared 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_LOG | Log 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.
| Variable | Purpose |
|---|---|
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_HOST | SMTP server. Empty (the default) turns email sending off. |
DOUSEN_SMTP_PORT | The default is 587 (STARTTLS). |
DOUSEN_SMTP_USER / DOUSEN_SMTP_PASSWORD | SMTP login credentials. |
DOUSEN_SMTP_FROM | From 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:
- Set
RUST_LOG=info,dousen_api::email=debugin.envand rundocker compose up -d. - 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:SHELL
docker compose logs dousen-api | grep "email (SMTP disabled)" - Give the link to the user yourself.
- Set
RUST_LOGback toinfo, 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).
| Variable | Purpose |
|---|---|
DOUSEN_LDAP_URL | Directory 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:
-
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. -
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). WithDOUSEN_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 state | Result | Log 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:
- Expiry: Core checks the expiry locally every hour. When the license passes the end of its grace
period, Core locks and logs
license expiry recheck failed: … — Core is now locked. - Heartbeat: unless
DOUSEN_CONTROL_PLANE_URLis empty, Core sends the control plane a heartbeat with its user count at startup and then every 24 hours. If the control plane replies with a JSON 403 whose code isrevoked,cancelled, orsuspended, Core locks and logslicense <code> by control plane: … — Core is now locked. Any other 403, such as a block page from a proxy or firewall, is logged as a warning and doesn’t lock the instance. Other error responses and network failures are only logged, and Core retries on the next heartbeat.
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
-
Backups: the database is in the
dousen-mariadbDocker volume.docker volume lslists it as<project>_dousen-mariadb, where the project name defaults to the name of the install directory. Include the volume in your backup routine. A logical dump looks like this:SHELL
The single quotes make the container’s shell expanddocker exec dousen-mariadb sh -c \ 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" dousen' \ > dousen-backup-$(date +%Y%m%d).sqlMARIADB_ROOT_PASSWORD, which exists only inside the container. Back up.envtoo, because it holds your license key and secrets, and store it outside the Docker volume. - Upgrades: change
DOUSEN_VERSION, then rundocker compose pull && docker compose up -d. Migrations run on start. If you later roll back to a version older than migration 0032, deactivated accounts can sign in again. - Perforce in Docker: the compose file includes a commented-out
p4dservice for a self-contained evaluation stack. - Kubernetes: Docker Compose is the supported path for self-hosted studios. The Helm chart in
infra/helm/dousencore/uses the same environment variables but is built for the managed GKE deployment that DousenControl provisions (Cloud SQL, Workload Identity, and a cert-manager ingress). To use it on another cluster, setmariadb.enabled: trueand replace the ingress and Cloud SQL values instead of runninghelm installunmodified.
Troubleshooting
| Symptom | Check |
|---|---|
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 logs | Run docker compose logs -f dousen-api. |