Install with Docker Compose
This is the recommended way to install Lunar Paper. Create a private .env file,
paste the commands below to create compose.yaml, and start the application
and its supporting services with Docker Compose. The complete configuration is
included in this guide. It uses HTTP on host port 3000 by default; you can
choose another host port. For encrypted access, use
Docker Compose with HTTPS.
These installation examples target 0.8.1, published in
lunarpaper/lunarpaper. Set
LUNAR_PAPER_VERSION=0.8.1 in .env to select the platform release. PostgreSQL
uses the official postgres:17-alpine image and has its own version; there is
no matching Lunar Paper database image to select.
Before you start
Section titled “Before you start”Use Docker Compose 2.23.1 or later. The storage configuration is included
in compose.yaml, using inline configs.
Choose the operating system of the computer or server that will run Lunar Paper. Your choice applies to every operating-system tab below and is remembered for your next visit. Commands outside tabs work on all three systems.
Install Docker Desktop for Windows with the WSL 2 backend and Linux containers, then start Docker Desktop. Docker Desktop includes Docker Compose.
Open PowerShell and check Docker and Compose:
docker versiondocker compose versionUse PowerShell for the commands in this guide.
Install Docker Engine and the Docker Compose plugin, and make sure the Docker service is running.
Open a terminal and check Docker, Compose, and OpenSSL. Install OpenSSL with your package manager if it is missing; the secret generator requires it.
docker versiondocker compose versionopenssl versionIf your Docker setup requires sudo, use it for the Docker commands below.
Install Docker Desktop for Mac for your processor, then start Docker Desktop. Docker Desktop includes Docker Compose. See Install Docker for Lunar Paper’s processor support and Apple silicon emulation.
Open Terminal and check Docker, Compose, and OpenSSL. The secret generator requires OpenSSL.
docker versiondocker compose versionopenssl versiondocker version should show both Client and Server details. If it cannot
connect to the server, start Docker Desktop or the Docker service. See
Install Docker for more help.
- Choose an unused host port. The example uses 3000; you can change it.
- Decide which address people will use to open Lunar Paper, such as
http://192.168.1.20:3000. Usehttp://localhost:3000if only this computer will use it. - Select a published platform release. Keep PostgreSQL on major version 17; a platform upgrade does not upgrade the PostgreSQL engine.
Running multiple instances
You can install a fresh copy while another Lunar Paper instance keeps running. Each instance needs its own folder, Compose project name, available host port, cookie namespace and generated secrets.
For the additional instance, use a separate folder such as lunarpaper-test
in step 1. Generate a new .env in that folder using step 2, then add or update
these settings before starting any containers:
COMPOSE_PROJECT_NAME=lunarpaper-testAPP_URL=http://localhost:3100HTTP_BIND_ADDRESS=127.0.0.1HOST_PORT=3100COOKIE_NAMESPACE=lunarpaper_testChoose a project name that has never been used on this Docker host if you want
empty storage. Use a different available port if 3100 is already occupied.
The cookie namespace keeps browser sessions separate when both instances use
localhost; ports alone do not separate cookies.
Keep the default storage settings:
POSTGRES_DATA=postgres-dataREDIS_DATA=redis-dataUPLOADS_DATA=uploads-dataSEAWEEDFS_DATA=seaweedfs-dataCOMPOSE_PROJECT_NAME overrides the name: lunarpaper in compose.yaml.
Docker prefixes these named volumes with the project name, so this example
creates separate storage such as lunarpaper-test_postgres-data, along with
separate containers and networks. A different folder alone does not override
the fixed project name. Reusing a previous project name can reuse its old volumes
and database passwords, even after its containers or working folder were removed.
If you use absolute host folders for storage instead, choose separate folders for every instance. Changing the project name does not separate shared host paths.
Continue with the normal validation and startup steps from the new folder, then
open http://localhost:3100 and complete keyed setup using
this instance’s own SETUP_KEY. Your existing instance can keep running.
Keep each instance’s .env with its data and retain its project name on upgrades.
Install
Section titled “Install”-
Create the installation folder.
Terminal window mkdir lunarpapercd lunarpaperRun the remaining commands here. Keep
.envandcompose.yamltogether. -
Generate
.env. Paste this into your terminal to create the file and generate its secrets. An existing.envis kept unchanged.Terminal window & {$ErrorActionPreference = 'Stop'if (Test-Path -LiteralPath .env) {Write-Host 'Kept existing .env unchanged, including its settings and keys.'return}$template = @'# Lunar Paper settings. Keep this file private and back it up with your data:# it holds the keys that protect everything Lunar Paper stores.# Platform release. PostgreSQL is versioned independently.LUNAR_PAPER_VERSION=0.8.1# Optional image repository override. Do not include a tag; LUNAR_PAPER_VERSION supplies it.# LUNAR_PAPER_IMAGE=ghcr.io/lunarpaper/platform# Optional separate Compose instance; choose an unused name for fresh storage.# COMPOSE_PROJECT_NAME=lunarpaper-test# Optional cookie isolation for another instance on the same hostname.# COOKIE_NAMESPACE=lunarpaper_test# The address people type into their browser, with no slash at the end.# For example http://192.168.1.20:3000APP_URL=http://localhost:3000# HTTP access. Use your server's LAN IP for access from other computers.HTTP_BIND_ADDRESS=127.0.0.1HOST_PORT=3000# Storage: keep these named-volume defaults, or use absolute host folder paths.# Changing a value does not move existing data.POSTGRES_DATA=postgres-dataREDIS_DATA=redis-dataUPLOADS_DATA=uploads-dataSEAWEEDFS_DATA=seaweedfs-data# Database usernames: defaults below; optionally override before first setup.# Custom names: start with a lowercase letter, then lowercase letters, digits or# underscores (max 63 characters). Use three distinct names. Keep app_* and pg_*# prefixes, postgres, public, svc_datalayer and reporting reserved for the defaults/system.PGUSER=postgresAPP_PLATFORM_USER=app_platformAPP_INTAKE_USER=app_intake# The first administrator. The password needs 12 or more characters, including# an upper-case letter, a lower-case letter, a number and a symbol.SYSTEM_ADMIN_USERNAME=you@example.comSYSTEM_ADMIN_PASSWORD=replace-with-your-password# Generated keys and passwords. Never change them after the first start:# some of them unlock data that is already encrypted.SETUP_KEY=__LUNAR_PAPER_RANDOM_KEY__# Database passwords are generated independently for the usernames above.PGPASSWORD=__LUNAR_PAPER_RANDOM_KEY__APP_PLATFORM_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__APP_INTAKE_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__REDIS_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__SESSION_SECRET=__LUNAR_PAPER_RANDOM_KEY__ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__INTERNAL_API_TOKEN=__LUNAR_PAPER_RANDOM_KEY__AI_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__MFA_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__SSO_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__DATA_SOURCE_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__WORKFLOW_CREDENTIAL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__PUBLISHER_CREDENTIAL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__EMAIL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__ADDRESS_PROVIDER_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__EMAIL_TRACKING_SECRET=__LUNAR_PAPER_RANDOM_KEY__PDF_EXPORT_SECRET=__LUNAR_PAPER_RANDOM_KEY__FORM_FILE_ENCRYPTION_KEYS={"v1":"__LUNAR_PAPER_RANDOM_KEY__"}'@$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()try {$lines = foreach ($line in ($template -split "\r?\n")) {if ($line.Contains('__LUNAR_PAPER_RANDOM_KEY__')) {$bytes = New-Object byte[] 32$rng.GetBytes($bytes)$key = ([BitConverter]::ToString($bytes) -replace '-', '').ToLower()$line.Replace('__LUNAR_PAPER_RANDOM_KEY__', $key)} else { $line }}$content = ($lines -join [Environment]::NewLine) + [Environment]::NewLine$path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath('.env')$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)try {$bytes = [System.Text.Encoding]::UTF8.GetBytes($content)$stream.Write($bytes, 0, $bytes.Length)} finally { $stream.Dispose() }} finally { $rng.Dispose() }Write-Host 'Created .env. Open it with: notepad .env, then replace the example address and administrator settings.'}Terminal window (set -euCif [ -e .env ] || [ -L .env ]; thenprintf '%s\n' 'Kept existing .env unchanged, including its settings and keys.'exit 0fiumask 077content=$(while IFS= read -r line; doif [[ "$line" == *'__LUNAR_PAPER_RANDOM_KEY__'* ]]; thenkey=$(openssl rand -hex 32) || exit 1[[ "$key" =~ ^[0-9a-f]{64}$ ]] || exit 1line="${line/__LUNAR_PAPER_RANDOM_KEY__/$key}"fiprintf '%s\n' "$line"done <<'LUNAR_PAPER_ENV'# Lunar Paper settings. Keep this file private and back it up with your data:# it holds the keys that protect everything Lunar Paper stores.# Platform release. PostgreSQL is versioned independently.LUNAR_PAPER_VERSION=0.8.1# Optional image repository override. Do not include a tag; LUNAR_PAPER_VERSION supplies it.# LUNAR_PAPER_IMAGE=ghcr.io/lunarpaper/platform# Optional separate Compose instance; choose an unused name for fresh storage.# COMPOSE_PROJECT_NAME=lunarpaper-test# Optional cookie isolation for another instance on the same hostname.# COOKIE_NAMESPACE=lunarpaper_test# The address people type into their browser, with no slash at the end.# For example http://192.168.1.20:3000APP_URL=http://localhost:3000# HTTP access. Use your server's LAN IP for access from other computers.HTTP_BIND_ADDRESS=127.0.0.1HOST_PORT=3000# Storage: keep these named-volume defaults, or use absolute host folder paths.# Changing a value does not move existing data.POSTGRES_DATA=postgres-dataREDIS_DATA=redis-dataUPLOADS_DATA=uploads-dataSEAWEEDFS_DATA=seaweedfs-data# Database usernames: defaults below; optionally override before first setup.# Custom names: start with a lowercase letter, then lowercase letters, digits or# underscores (max 63 characters). Use three distinct names. Keep app_* and pg_*# prefixes, postgres, public, svc_datalayer and reporting reserved for the defaults/system.PGUSER=postgresAPP_PLATFORM_USER=app_platformAPP_INTAKE_USER=app_intake# The first administrator. The password needs 12 or more characters, including# an upper-case letter, a lower-case letter, a number and a symbol.SYSTEM_ADMIN_USERNAME=you@example.comSYSTEM_ADMIN_PASSWORD=replace-with-your-password# Generated keys and passwords. Never change them after the first start:# some of them unlock data that is already encrypted.SETUP_KEY=__LUNAR_PAPER_RANDOM_KEY__# Database passwords are generated independently for the usernames above.PGPASSWORD=__LUNAR_PAPER_RANDOM_KEY__APP_PLATFORM_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__APP_INTAKE_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__REDIS_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__SESSION_SECRET=__LUNAR_PAPER_RANDOM_KEY__ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__INTERNAL_API_TOKEN=__LUNAR_PAPER_RANDOM_KEY__AI_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__MFA_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__SSO_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__DATA_SOURCE_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__WORKFLOW_CREDENTIAL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__PUBLISHER_CREDENTIAL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__EMAIL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__ADDRESS_PROVIDER_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__EMAIL_TRACKING_SECRET=__LUNAR_PAPER_RANDOM_KEY__PDF_EXPORT_SECRET=__LUNAR_PAPER_RANDOM_KEY__FORM_FILE_ENCRYPTION_KEYS={"v1":"__LUNAR_PAPER_RANDOM_KEY__"}LUNAR_PAPER_ENV)printf '%s\n' "$content" > .envprintf '%s\n' 'Created .env. Open it and replace the example address and administrator settings.')Terminal window (set -euCif [ -e .env ] || [ -L .env ]; thenprintf '%s\n' 'Kept existing .env unchanged, including its settings and keys.'exit 0fiumask 077content=$(while IFS= read -r line; doif [[ "$line" == *'__LUNAR_PAPER_RANDOM_KEY__'* ]]; thenkey=$(openssl rand -hex 32) || exit 1[[ "$key" =~ ^[0-9a-f]{64}$ ]] || exit 1line="${line/__LUNAR_PAPER_RANDOM_KEY__/$key}"fiprintf '%s\n' "$line"done <<'LUNAR_PAPER_ENV'# Lunar Paper settings. Keep this file private and back it up with your data:# it holds the keys that protect everything Lunar Paper stores.# Platform release. PostgreSQL is versioned independently.LUNAR_PAPER_VERSION=0.8.1# Optional image repository override. Do not include a tag; LUNAR_PAPER_VERSION supplies it.# LUNAR_PAPER_IMAGE=ghcr.io/lunarpaper/platform# Optional separate Compose instance; choose an unused name for fresh storage.# COMPOSE_PROJECT_NAME=lunarpaper-test# Optional cookie isolation for another instance on the same hostname.# COOKIE_NAMESPACE=lunarpaper_test# The address people type into their browser, with no slash at the end.# For example http://192.168.1.20:3000APP_URL=http://localhost:3000# HTTP access. Use your server's LAN IP for access from other computers.HTTP_BIND_ADDRESS=127.0.0.1HOST_PORT=3000# Storage: keep these named-volume defaults, or use absolute host folder paths.# Changing a value does not move existing data.POSTGRES_DATA=postgres-dataREDIS_DATA=redis-dataUPLOADS_DATA=uploads-dataSEAWEEDFS_DATA=seaweedfs-data# Database usernames: defaults below; optionally override before first setup.# Custom names: start with a lowercase letter, then lowercase letters, digits or# underscores (max 63 characters). Use three distinct names. Keep app_* and pg_*# prefixes, postgres, public, svc_datalayer and reporting reserved for the defaults/system.PGUSER=postgresAPP_PLATFORM_USER=app_platformAPP_INTAKE_USER=app_intake# The first administrator. The password needs 12 or more characters, including# an upper-case letter, a lower-case letter, a number and a symbol.SYSTEM_ADMIN_USERNAME=you@example.comSYSTEM_ADMIN_PASSWORD=replace-with-your-password# Generated keys and passwords. Never change them after the first start:# some of them unlock data that is already encrypted.SETUP_KEY=__LUNAR_PAPER_RANDOM_KEY__# Database passwords are generated independently for the usernames above.PGPASSWORD=__LUNAR_PAPER_RANDOM_KEY__APP_PLATFORM_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__APP_INTAKE_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__REDIS_PASSWORD=__LUNAR_PAPER_RANDOM_KEY__SESSION_SECRET=__LUNAR_PAPER_RANDOM_KEY__ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__INTERNAL_API_TOKEN=__LUNAR_PAPER_RANDOM_KEY__AI_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__MFA_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__SSO_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__DATA_SOURCE_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__WORKFLOW_CREDENTIAL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__PUBLISHER_CREDENTIAL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__EMAIL_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__ADDRESS_PROVIDER_ENCRYPTION_KEY=__LUNAR_PAPER_RANDOM_KEY__EMAIL_TRACKING_SECRET=__LUNAR_PAPER_RANDOM_KEY__PDF_EXPORT_SECRET=__LUNAR_PAPER_RANDOM_KEY__FORM_FILE_ENCRYPTION_KEYS={"v1":"__LUNAR_PAPER_RANDOM_KEY__"}LUNAR_PAPER_ENV)printf '%s\n' "$content" > .envprintf '%s\n' 'Created .env. Open it and replace the example address and administrator settings.')Optionally create .env manually
Create
.envin your installation folder and paste this template. Replace every shortened value containing…with a separately generated 64-character hexadecimal secret, including the value insideFORM_FILE_ENCRYPTION_KEYS. Then edit the settings in step 3. Preserve existing secrets when updating an installation..env # Lunar Paper settings. Keep this file private and back it up with your data:# it holds the keys that protect everything Lunar Paper stores.# Platform release. PostgreSQL is versioned independently.LUNAR_PAPER_VERSION=0.8.1# Optional image repository override. Do not include a tag; LUNAR_PAPER_VERSION supplies it.# LUNAR_PAPER_IMAGE=ghcr.io/lunarpaper/platform# Optional separate Compose instance; choose an unused name for fresh storage.# COMPOSE_PROJECT_NAME=lunarpaper-test# Optional cookie isolation for another instance on the same hostname.# COOKIE_NAMESPACE=lunarpaper_test# The address people type into their browser, with no slash at the end.# For example http://192.168.1.20:3000APP_URL=http://localhost:3000# HTTP access. Use your server's LAN IP for access from other computers.HTTP_BIND_ADDRESS=127.0.0.1HOST_PORT=3000# Storage: keep these named-volume defaults, or use absolute host folder paths.# Changing a value does not move existing data.POSTGRES_DATA=postgres-dataREDIS_DATA=redis-dataUPLOADS_DATA=uploads-dataSEAWEEDFS_DATA=seaweedfs-data# Database usernames: defaults below; optionally override before first setup.# Custom names: start with a lowercase letter, then lowercase letters, digits or# underscores (max 63 characters). Use three distinct names. Keep app_* and pg_*# prefixes, postgres, public, svc_datalayer and reporting reserved for the defaults/system.PGUSER=postgresAPP_PLATFORM_USER=app_platformAPP_INTAKE_USER=app_intake# The first administrator. The password needs 12 or more characters, including# an upper-case letter, a lower-case letter, a number and a symbol.SYSTEM_ADMIN_USERNAME=you@example.comSYSTEM_ADMIN_PASSWORD=replace-with-your-password# Generated keys and passwords. Never change them after the first start:# some of them unlock data that is already encrypted.SETUP_KEY=6a0a1971…0efd# Database passwords are generated independently for the usernames above.PGPASSWORD=11ab02a5…6096APP_PLATFORM_PASSWORD=330060eb…2c70APP_INTAKE_PASSWORD=6cd87778…5958REDIS_PASSWORD=a5bf071e…70a7SESSION_SECRET=abf73647…b717ENCRYPTION_KEY=1c91c9a9…0661INTERNAL_API_TOKEN=ac08c5eb…24f9AI_ENCRYPTION_KEY=6b70d6de…a4f9MFA_ENCRYPTION_KEY=e0e02f3e…15beSSO_ENCRYPTION_KEY=73df63d5…df83DATA_SOURCE_ENCRYPTION_KEY=2319ed4c…137fWORKFLOW_CREDENTIAL_ENCRYPTION_KEY=ac2cfe15…5616PUBLISHER_CREDENTIAL_ENCRYPTION_KEY=a19370a3…c8deEMAIL_ENCRYPTION_KEY=05e86d1d…0344ADDRESS_PROVIDER_ENCRYPTION_KEY=ef542acf…1152EMAIL_TRACKING_SECRET=55ba1312…adaaPDF_EXPORT_SECRET=927c1659…7f0eFORM_FILE_ENCRYPTION_KEYS={"v1":"ee7f9ce6…6432"} -
Edit your settings. Open
.env:Terminal window notepad .envSave the file after editing.
Terminal window nano .envAfter editing, press Ctrl+O, Enter and Ctrl+X to save and exit.
Terminal window nano .envAfter editing, press Control+O, Enter and Control+X to save and exit.
Set your browser address, administrator email and password:
APP_URL=http://localhost:3000SYSTEM_ADMIN_USERNAME=you@example.comSYSTEM_ADMIN_PASSWORD=replace-with-your-passwordAPP_URLmust match the browser address exactly, with no slash at the end. The password needs 12 or more characters, with upper case, lower case, a number and a symbol, and no spaces or quotes. Leave the generated lines unchanged.The defaults work on this computer:
HTTP_BIND_ADDRESS=127.0.0.1andAPP_URL=http://localhost:3000. For access from other computers, changeHTTP_BIND_ADDRESSto an IP address actually assigned to your server and use the same address inAPP_URL. Example LAN addresses in this guide are placeholders; do not copy them unless they match your server.HOST_PORTdefaults to3000; if you change it, update the port inAPP_URLtoo.To customize database usernames before the first start, see Database usernames.
-
Generate
compose.yaml. Paste this into your terminal to create the file. An existingcompose.yamlis kept unchanged.Terminal window & {$ErrorActionPreference = 'Stop'$files = [ordered]@{'compose.yaml' = @'# Lunar Paper with official PostgreSQL. Select the platform version in your private environment.# Initialize or upgrade application schemas explicitly through protected web setup.name: lunarpaperservices:lunarpaper:image: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}restart: unless-stoppedenvironment:NODE_ENV: productionPORT: "3000"PROCESS_ROLE: allCLUSTER_WORKERS: "1"# One browser URL controls origins and cookie security for HTTP.APP_URL: ${APP_URL:?APP_URL is required}API_URL: ${APP_URL:?APP_URL is required}ALLOWED_ORIGINS: ${APP_URL:?APP_URL is required}PUBLISHER_CALLBACK_ORIGINS: ${APP_URL:?APP_URL is required}# Keyed web setup and manual upgrades are fixed application behaviour.SETUP_KEY: ${SETUP_KEY:?SETUP_KEY is required}# Forward the same image selection to the app's release checks.LUNAR_PAPER_IMAGE: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}SYSTEM_ADMIN_USERNAME: ${SYSTEM_ADMIN_USERNAME:?SYSTEM_ADMIN_USERNAME is required}SYSTEM_ADMIN_PASSWORD: ${SYSTEM_ADMIN_PASSWORD:?SYSTEM_ADMIN_PASSWORD is required}SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required}COOKIE_NAMESPACE: ${COOKIE_NAMESPACE:-}ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}WORKFLOW_CREDENTIAL_ENCRYPTION_KEY: ${WORKFLOW_CREDENTIAL_ENCRYPTION_KEY:?WORKFLOW_CREDENTIAL_ENCRYPTION_KEY is required}INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:?INTERNAL_API_TOKEN is required}AI_ENCRYPTION_KEY: ${AI_ENCRYPTION_KEY:?AI_ENCRYPTION_KEY is required}MFA_ENCRYPTION_KEY: ${MFA_ENCRYPTION_KEY:?MFA_ENCRYPTION_KEY is required}SSO_ENCRYPTION_KEY: ${SSO_ENCRYPTION_KEY:?SSO_ENCRYPTION_KEY is required}DATA_SOURCE_ENCRYPTION_KEY: ${DATA_SOURCE_ENCRYPTION_KEY:?DATA_SOURCE_ENCRYPTION_KEY is required}PUBLISHER_CREDENTIAL_ENCRYPTION_KEY: ${PUBLISHER_CREDENTIAL_ENCRYPTION_KEY:?PUBLISHER_CREDENTIAL_ENCRYPTION_KEY is required}PUBLISHER_PRIVATE_TARGETS: ${PUBLISHER_PRIVATE_TARGETS:-}EMAIL_ENCRYPTION_KEY: ${EMAIL_ENCRYPTION_KEY:?EMAIL_ENCRYPTION_KEY is required}ADDRESS_PROVIDER_ENCRYPTION_KEY: ${ADDRESS_PROVIDER_ENCRYPTION_KEY:?ADDRESS_PROVIDER_ENCRYPTION_KEY is required}EMAIL_TRACKING_SECRET: ${EMAIL_TRACKING_SECRET:?EMAIL_TRACKING_SECRET is required}PDF_EXPORT_SECRET: ${PDF_EXPORT_SECRET:?PDF_EXPORT_SECRET is required}FORM_FILE_ENCRYPTION_KEYS: ${FORM_FILE_ENCRYPTION_KEYS:?FORM_FILE_ENCRYPTION_KEYS is required}FORM_FILE_ENCRYPTION_ACTIVE_KEY_ID: v1# Optional: Compose omits unset entries from the container environment.# See the commented remote-service example in .env.example.DATA_LAYER_ENABLED:DATA_LAYER_URL:DATA_LAYER_API_KEY:DATA_LAYER_ALLOWED_ENDPOINTS:# Two databases, each with a runtime URL and a separate setup/upgrade owner URL.PGHOST: postgresPGPORT: "5432"PGDATABASE: platformPGUSER: ${PGUSER:-postgres}PGPASSWORD: ${PGPASSWORD:?PGPASSWORD is required}APP_PLATFORM_USER: ${APP_PLATFORM_USER:-app_platform}APP_INTAKE_USER: ${APP_INTAKE_USER:-app_intake}DATABASE_URL: postgresql://${APP_PLATFORM_USER:-app_platform}:${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}@postgres:5432/platformINTAKE_DATABASE_URL: postgresql://${APP_INTAKE_USER:-app_intake}:${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}@postgres:5432/intakeAPP_PLATFORM_PASSWORD: ${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}APP_INTAKE_PASSWORD: ${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}MIGRATION_PLATFORM_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/platformMIGRATION_INTAKE_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/intakeAPP_DB_ROLE: "true"DB_POOL_MAX: "40"SESSION_STORE: redisREDIS_REQUIRED: "true"REDIS_HOST: redisREDIS_PORT: "6379"REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}SEAWEEDFS_MASTER_URL: http://seaweedfs:9333SEAWEEDFS_FILER_URL: http://seaweedfs:8888SEAWEEDFS_VOLUME_URL: http://seaweedfs:8080ports:- "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"volumes:- "${UPLOADS_DATA:-uploads-data}:/app/uploads"depends_on:postgres:condition: service_healthyredis:condition: service_healthyseaweedfs:condition: service_healthyhealthcheck:test:- CMD- node- -e- "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"interval: 15stimeout: 5sretries: 3start_period: 120snetworks:- edge- backendpostgres:image: ${POSTGRES_IMAGE:-postgres:17-alpine}restart: unless-stoppedenvironment:POSTGRES_DB: platformPOSTGRES_USER: ${PGUSER:-postgres}POSTGRES_PASSWORD: ${PGPASSWORD:?PGPASSWORD is required}command: ["postgres", "-c", "max_connections=200"]volumes:- "${POSTGRES_DATA:-postgres-data}:/var/lib/postgresql/data"healthcheck:test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendredis:image: redis:7-alpinerestart: unless-stoppedenvironment:REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}"]volumes:- "${REDIS_DATA:-redis-data}:/data"healthcheck:test: ["CMD-SHELL", "REDISCLI_AUTH=\"$${REDIS_PASSWORD}\" redis-cli ping | grep -q PONG"]interval: 10stimeout: 5sretries: 12start_period: 10snetworks:- backend# Master, volume server and filer together for a single-server installation.seaweedfs:image: chrislusf/seaweedfs:4.47restart: unless-stoppedworking_dir: /datacommand:- server- -ip=seaweedfs- -ip.bind=0.0.0.0- -dir=/data- -master.volumeSizeLimitMB=1024- -volume.max=0- -filervolumes:- "${SEAWEEDFS_DATA:-seaweedfs-data}:/data"configs:- source: filertarget: /etc/seaweedfs/filer.tomlhealthcheck:test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status && wget -q -O /dev/null http://127.0.0.1:8888/"]interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendvolumes:postgres-data:redis-data:uploads-data:seaweedfs-data:networks:edge:backend:internal: trueconfigs:filer:content: |# Persist the file directory/index alongside the file contents in the data volume.[leveldb2]enabled = truedir = "/data/filerldb2"'@}foreach ($file in $files.GetEnumerator()) {$destination = Join-Path . $file.Keyif (Test-Path -LiteralPath $destination) {Write-Host "Kept existing $destination"continue}$stream = [System.IO.File]::Open($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($destination), [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write)try {$bytes = [System.Text.Encoding]::UTF8.GetBytes($file.Value + [Environment]::NewLine)$stream.Write($bytes, 0, $bytes.Length)} finally { $stream.Dispose() }Write-Host "Created $destination"}}Terminal window (set -euCif [ -e ./compose.yaml ] || [ -L ./compose.yaml ]; thenprintf '%s\n' 'Kept existing ./compose.yaml'elsecat > ./compose.yaml <<'LUNAR_PAPER_FILE'# Lunar Paper with official PostgreSQL. Select the platform version in your private environment.# Initialize or upgrade application schemas explicitly through protected web setup.name: lunarpaperservices:lunarpaper:image: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}restart: unless-stoppedenvironment:NODE_ENV: productionPORT: "3000"PROCESS_ROLE: allCLUSTER_WORKERS: "1"# One browser URL controls origins and cookie security for HTTP.APP_URL: ${APP_URL:?APP_URL is required}API_URL: ${APP_URL:?APP_URL is required}ALLOWED_ORIGINS: ${APP_URL:?APP_URL is required}PUBLISHER_CALLBACK_ORIGINS: ${APP_URL:?APP_URL is required}# Keyed web setup and manual upgrades are fixed application behaviour.SETUP_KEY: ${SETUP_KEY:?SETUP_KEY is required}# Forward the same image selection to the app's release checks.LUNAR_PAPER_IMAGE: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}SYSTEM_ADMIN_USERNAME: ${SYSTEM_ADMIN_USERNAME:?SYSTEM_ADMIN_USERNAME is required}SYSTEM_ADMIN_PASSWORD: ${SYSTEM_ADMIN_PASSWORD:?SYSTEM_ADMIN_PASSWORD is required}SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required}COOKIE_NAMESPACE: ${COOKIE_NAMESPACE:-}ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}WORKFLOW_CREDENTIAL_ENCRYPTION_KEY: ${WORKFLOW_CREDENTIAL_ENCRYPTION_KEY:?WORKFLOW_CREDENTIAL_ENCRYPTION_KEY is required}INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:?INTERNAL_API_TOKEN is required}AI_ENCRYPTION_KEY: ${AI_ENCRYPTION_KEY:?AI_ENCRYPTION_KEY is required}MFA_ENCRYPTION_KEY: ${MFA_ENCRYPTION_KEY:?MFA_ENCRYPTION_KEY is required}SSO_ENCRYPTION_KEY: ${SSO_ENCRYPTION_KEY:?SSO_ENCRYPTION_KEY is required}DATA_SOURCE_ENCRYPTION_KEY: ${DATA_SOURCE_ENCRYPTION_KEY:?DATA_SOURCE_ENCRYPTION_KEY is required}PUBLISHER_CREDENTIAL_ENCRYPTION_KEY: ${PUBLISHER_CREDENTIAL_ENCRYPTION_KEY:?PUBLISHER_CREDENTIAL_ENCRYPTION_KEY is required}PUBLISHER_PRIVATE_TARGETS: ${PUBLISHER_PRIVATE_TARGETS:-}EMAIL_ENCRYPTION_KEY: ${EMAIL_ENCRYPTION_KEY:?EMAIL_ENCRYPTION_KEY is required}ADDRESS_PROVIDER_ENCRYPTION_KEY: ${ADDRESS_PROVIDER_ENCRYPTION_KEY:?ADDRESS_PROVIDER_ENCRYPTION_KEY is required}EMAIL_TRACKING_SECRET: ${EMAIL_TRACKING_SECRET:?EMAIL_TRACKING_SECRET is required}PDF_EXPORT_SECRET: ${PDF_EXPORT_SECRET:?PDF_EXPORT_SECRET is required}FORM_FILE_ENCRYPTION_KEYS: ${FORM_FILE_ENCRYPTION_KEYS:?FORM_FILE_ENCRYPTION_KEYS is required}FORM_FILE_ENCRYPTION_ACTIVE_KEY_ID: v1# Optional: Compose omits unset entries from the container environment.# See the commented remote-service example in .env.example.DATA_LAYER_ENABLED:DATA_LAYER_URL:DATA_LAYER_API_KEY:DATA_LAYER_ALLOWED_ENDPOINTS:# Two databases, each with a runtime URL and a separate setup/upgrade owner URL.PGHOST: postgresPGPORT: "5432"PGDATABASE: platformPGUSER: ${PGUSER:-postgres}PGPASSWORD: ${PGPASSWORD:?PGPASSWORD is required}APP_PLATFORM_USER: ${APP_PLATFORM_USER:-app_platform}APP_INTAKE_USER: ${APP_INTAKE_USER:-app_intake}DATABASE_URL: postgresql://${APP_PLATFORM_USER:-app_platform}:${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}@postgres:5432/platformINTAKE_DATABASE_URL: postgresql://${APP_INTAKE_USER:-app_intake}:${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}@postgres:5432/intakeAPP_PLATFORM_PASSWORD: ${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}APP_INTAKE_PASSWORD: ${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}MIGRATION_PLATFORM_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/platformMIGRATION_INTAKE_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/intakeAPP_DB_ROLE: "true"DB_POOL_MAX: "40"SESSION_STORE: redisREDIS_REQUIRED: "true"REDIS_HOST: redisREDIS_PORT: "6379"REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}SEAWEEDFS_MASTER_URL: http://seaweedfs:9333SEAWEEDFS_FILER_URL: http://seaweedfs:8888SEAWEEDFS_VOLUME_URL: http://seaweedfs:8080ports:- "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"volumes:- "${UPLOADS_DATA:-uploads-data}:/app/uploads"depends_on:postgres:condition: service_healthyredis:condition: service_healthyseaweedfs:condition: service_healthyhealthcheck:test:- CMD- node- -e- "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"interval: 15stimeout: 5sretries: 3start_period: 120snetworks:- edge- backendpostgres:image: ${POSTGRES_IMAGE:-postgres:17-alpine}restart: unless-stoppedenvironment:POSTGRES_DB: platformPOSTGRES_USER: ${PGUSER:-postgres}POSTGRES_PASSWORD: ${PGPASSWORD:?PGPASSWORD is required}command: ["postgres", "-c", "max_connections=200"]volumes:- "${POSTGRES_DATA:-postgres-data}:/var/lib/postgresql/data"healthcheck:test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendredis:image: redis:7-alpinerestart: unless-stoppedenvironment:REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}"]volumes:- "${REDIS_DATA:-redis-data}:/data"healthcheck:test: ["CMD-SHELL", "REDISCLI_AUTH=\"$${REDIS_PASSWORD}\" redis-cli ping | grep -q PONG"]interval: 10stimeout: 5sretries: 12start_period: 10snetworks:- backend# Master, volume server and filer together for a single-server installation.seaweedfs:image: chrislusf/seaweedfs:4.47restart: unless-stoppedworking_dir: /datacommand:- server- -ip=seaweedfs- -ip.bind=0.0.0.0- -dir=/data- -master.volumeSizeLimitMB=1024- -volume.max=0- -filervolumes:- "${SEAWEEDFS_DATA:-seaweedfs-data}:/data"configs:- source: filertarget: /etc/seaweedfs/filer.tomlhealthcheck:test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status && wget -q -O /dev/null http://127.0.0.1:8888/"]interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendvolumes:postgres-data:redis-data:uploads-data:seaweedfs-data:networks:edge:backend:internal: trueconfigs:filer:content: |# Persist the file directory/index alongside the file contents in the data volume.[leveldb2]enabled = truedir = "/data/filerldb2"LUNAR_PAPER_FILEprintf '%s\n' 'Created ./compose.yaml'fi)Terminal window (set -euCif [ -e ./compose.yaml ] || [ -L ./compose.yaml ]; thenprintf '%s\n' 'Kept existing ./compose.yaml'elsecat > ./compose.yaml <<'LUNAR_PAPER_FILE'# Lunar Paper with official PostgreSQL. Select the platform version in your private environment.# Initialize or upgrade application schemas explicitly through protected web setup.name: lunarpaperservices:lunarpaper:image: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}restart: unless-stoppedenvironment:NODE_ENV: productionPORT: "3000"PROCESS_ROLE: allCLUSTER_WORKERS: "1"# One browser URL controls origins and cookie security for HTTP.APP_URL: ${APP_URL:?APP_URL is required}API_URL: ${APP_URL:?APP_URL is required}ALLOWED_ORIGINS: ${APP_URL:?APP_URL is required}PUBLISHER_CALLBACK_ORIGINS: ${APP_URL:?APP_URL is required}# Keyed web setup and manual upgrades are fixed application behaviour.SETUP_KEY: ${SETUP_KEY:?SETUP_KEY is required}# Forward the same image selection to the app's release checks.LUNAR_PAPER_IMAGE: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}SYSTEM_ADMIN_USERNAME: ${SYSTEM_ADMIN_USERNAME:?SYSTEM_ADMIN_USERNAME is required}SYSTEM_ADMIN_PASSWORD: ${SYSTEM_ADMIN_PASSWORD:?SYSTEM_ADMIN_PASSWORD is required}SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required}COOKIE_NAMESPACE: ${COOKIE_NAMESPACE:-}ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}WORKFLOW_CREDENTIAL_ENCRYPTION_KEY: ${WORKFLOW_CREDENTIAL_ENCRYPTION_KEY:?WORKFLOW_CREDENTIAL_ENCRYPTION_KEY is required}INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:?INTERNAL_API_TOKEN is required}AI_ENCRYPTION_KEY: ${AI_ENCRYPTION_KEY:?AI_ENCRYPTION_KEY is required}MFA_ENCRYPTION_KEY: ${MFA_ENCRYPTION_KEY:?MFA_ENCRYPTION_KEY is required}SSO_ENCRYPTION_KEY: ${SSO_ENCRYPTION_KEY:?SSO_ENCRYPTION_KEY is required}DATA_SOURCE_ENCRYPTION_KEY: ${DATA_SOURCE_ENCRYPTION_KEY:?DATA_SOURCE_ENCRYPTION_KEY is required}PUBLISHER_CREDENTIAL_ENCRYPTION_KEY: ${PUBLISHER_CREDENTIAL_ENCRYPTION_KEY:?PUBLISHER_CREDENTIAL_ENCRYPTION_KEY is required}PUBLISHER_PRIVATE_TARGETS: ${PUBLISHER_PRIVATE_TARGETS:-}EMAIL_ENCRYPTION_KEY: ${EMAIL_ENCRYPTION_KEY:?EMAIL_ENCRYPTION_KEY is required}ADDRESS_PROVIDER_ENCRYPTION_KEY: ${ADDRESS_PROVIDER_ENCRYPTION_KEY:?ADDRESS_PROVIDER_ENCRYPTION_KEY is required}EMAIL_TRACKING_SECRET: ${EMAIL_TRACKING_SECRET:?EMAIL_TRACKING_SECRET is required}PDF_EXPORT_SECRET: ${PDF_EXPORT_SECRET:?PDF_EXPORT_SECRET is required}FORM_FILE_ENCRYPTION_KEYS: ${FORM_FILE_ENCRYPTION_KEYS:?FORM_FILE_ENCRYPTION_KEYS is required}FORM_FILE_ENCRYPTION_ACTIVE_KEY_ID: v1# Optional: Compose omits unset entries from the container environment.# See the commented remote-service example in .env.example.DATA_LAYER_ENABLED:DATA_LAYER_URL:DATA_LAYER_API_KEY:DATA_LAYER_ALLOWED_ENDPOINTS:# Two databases, each with a runtime URL and a separate setup/upgrade owner URL.PGHOST: postgresPGPORT: "5432"PGDATABASE: platformPGUSER: ${PGUSER:-postgres}PGPASSWORD: ${PGPASSWORD:?PGPASSWORD is required}APP_PLATFORM_USER: ${APP_PLATFORM_USER:-app_platform}APP_INTAKE_USER: ${APP_INTAKE_USER:-app_intake}DATABASE_URL: postgresql://${APP_PLATFORM_USER:-app_platform}:${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}@postgres:5432/platformINTAKE_DATABASE_URL: postgresql://${APP_INTAKE_USER:-app_intake}:${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}@postgres:5432/intakeAPP_PLATFORM_PASSWORD: ${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}APP_INTAKE_PASSWORD: ${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}MIGRATION_PLATFORM_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/platformMIGRATION_INTAKE_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/intakeAPP_DB_ROLE: "true"DB_POOL_MAX: "40"SESSION_STORE: redisREDIS_REQUIRED: "true"REDIS_HOST: redisREDIS_PORT: "6379"REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}SEAWEEDFS_MASTER_URL: http://seaweedfs:9333SEAWEEDFS_FILER_URL: http://seaweedfs:8888SEAWEEDFS_VOLUME_URL: http://seaweedfs:8080ports:- "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"volumes:- "${UPLOADS_DATA:-uploads-data}:/app/uploads"depends_on:postgres:condition: service_healthyredis:condition: service_healthyseaweedfs:condition: service_healthyhealthcheck:test:- CMD- node- -e- "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"interval: 15stimeout: 5sretries: 3start_period: 120snetworks:- edge- backendpostgres:image: ${POSTGRES_IMAGE:-postgres:17-alpine}restart: unless-stoppedenvironment:POSTGRES_DB: platformPOSTGRES_USER: ${PGUSER:-postgres}POSTGRES_PASSWORD: ${PGPASSWORD:?PGPASSWORD is required}command: ["postgres", "-c", "max_connections=200"]volumes:- "${POSTGRES_DATA:-postgres-data}:/var/lib/postgresql/data"healthcheck:test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendredis:image: redis:7-alpinerestart: unless-stoppedenvironment:REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}"]volumes:- "${REDIS_DATA:-redis-data}:/data"healthcheck:test: ["CMD-SHELL", "REDISCLI_AUTH=\"$${REDIS_PASSWORD}\" redis-cli ping | grep -q PONG"]interval: 10stimeout: 5sretries: 12start_period: 10snetworks:- backend# Master, volume server and filer together for a single-server installation.seaweedfs:image: chrislusf/seaweedfs:4.47restart: unless-stoppedworking_dir: /datacommand:- server- -ip=seaweedfs- -ip.bind=0.0.0.0- -dir=/data- -master.volumeSizeLimitMB=1024- -volume.max=0- -filervolumes:- "${SEAWEEDFS_DATA:-seaweedfs-data}:/data"configs:- source: filertarget: /etc/seaweedfs/filer.tomlhealthcheck:test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status && wget -q -O /dev/null http://127.0.0.1:8888/"]interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendvolumes:postgres-data:redis-data:uploads-data:seaweedfs-data:networks:edge:backend:internal: trueconfigs:filer:content: |# Persist the file directory/index alongside the file contents in the data volume.[leveldb2]enabled = truedir = "/data/filerldb2"LUNAR_PAPER_FILEprintf '%s\n' 'Created ./compose.yaml'fi)Optionally create compose.yaml manually
Create
compose.yamlbeside.envand paste this configuration. Keep the${VARIABLE}expressions; Compose fills them from.env. To update an existing file, compare it with this example.compose.yaml # Lunar Paper with official PostgreSQL. Select the platform version in your private environment.# Initialize or upgrade application schemas explicitly through protected web setup.name: lunarpaperservices:lunarpaper:image: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}restart: unless-stoppedenvironment:NODE_ENV: productionPORT: "3000"PROCESS_ROLE: allCLUSTER_WORKERS: "1"# One browser URL controls origins and cookie security for HTTP.APP_URL: ${APP_URL:?APP_URL is required}API_URL: ${APP_URL:?APP_URL is required}ALLOWED_ORIGINS: ${APP_URL:?APP_URL is required}PUBLISHER_CALLBACK_ORIGINS: ${APP_URL:?APP_URL is required}# Keyed web setup and manual upgrades are fixed application behaviour.SETUP_KEY: ${SETUP_KEY:?SETUP_KEY is required}# Forward the same image selection to the app's release checks.LUNAR_PAPER_IMAGE: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}SYSTEM_ADMIN_USERNAME: ${SYSTEM_ADMIN_USERNAME:?SYSTEM_ADMIN_USERNAME is required}SYSTEM_ADMIN_PASSWORD: ${SYSTEM_ADMIN_PASSWORD:?SYSTEM_ADMIN_PASSWORD is required}SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET is required}COOKIE_NAMESPACE: ${COOKIE_NAMESPACE:-}ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}WORKFLOW_CREDENTIAL_ENCRYPTION_KEY: ${WORKFLOW_CREDENTIAL_ENCRYPTION_KEY:?WORKFLOW_CREDENTIAL_ENCRYPTION_KEY is required}INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:?INTERNAL_API_TOKEN is required}AI_ENCRYPTION_KEY: ${AI_ENCRYPTION_KEY:?AI_ENCRYPTION_KEY is required}MFA_ENCRYPTION_KEY: ${MFA_ENCRYPTION_KEY:?MFA_ENCRYPTION_KEY is required}SSO_ENCRYPTION_KEY: ${SSO_ENCRYPTION_KEY:?SSO_ENCRYPTION_KEY is required}DATA_SOURCE_ENCRYPTION_KEY: ${DATA_SOURCE_ENCRYPTION_KEY:?DATA_SOURCE_ENCRYPTION_KEY is required}PUBLISHER_CREDENTIAL_ENCRYPTION_KEY: ${PUBLISHER_CREDENTIAL_ENCRYPTION_KEY:?PUBLISHER_CREDENTIAL_ENCRYPTION_KEY is required}PUBLISHER_PRIVATE_TARGETS: ${PUBLISHER_PRIVATE_TARGETS:-}EMAIL_ENCRYPTION_KEY: ${EMAIL_ENCRYPTION_KEY:?EMAIL_ENCRYPTION_KEY is required}ADDRESS_PROVIDER_ENCRYPTION_KEY: ${ADDRESS_PROVIDER_ENCRYPTION_KEY:?ADDRESS_PROVIDER_ENCRYPTION_KEY is required}EMAIL_TRACKING_SECRET: ${EMAIL_TRACKING_SECRET:?EMAIL_TRACKING_SECRET is required}PDF_EXPORT_SECRET: ${PDF_EXPORT_SECRET:?PDF_EXPORT_SECRET is required}FORM_FILE_ENCRYPTION_KEYS: ${FORM_FILE_ENCRYPTION_KEYS:?FORM_FILE_ENCRYPTION_KEYS is required}FORM_FILE_ENCRYPTION_ACTIVE_KEY_ID: v1# Optional: Compose omits unset entries from the container environment.# See the commented remote-service example in .env.example.DATA_LAYER_ENABLED:DATA_LAYER_URL:DATA_LAYER_API_KEY:DATA_LAYER_ALLOWED_ENDPOINTS:# Two databases, each with a runtime URL and a separate setup/upgrade owner URL.PGHOST: postgresPGPORT: "5432"PGDATABASE: platformPGUSER: ${PGUSER:-postgres}PGPASSWORD: ${PGPASSWORD:?PGPASSWORD is required}APP_PLATFORM_USER: ${APP_PLATFORM_USER:-app_platform}APP_INTAKE_USER: ${APP_INTAKE_USER:-app_intake}DATABASE_URL: postgresql://${APP_PLATFORM_USER:-app_platform}:${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}@postgres:5432/platformINTAKE_DATABASE_URL: postgresql://${APP_INTAKE_USER:-app_intake}:${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}@postgres:5432/intakeAPP_PLATFORM_PASSWORD: ${APP_PLATFORM_PASSWORD:?APP_PLATFORM_PASSWORD is required}APP_INTAKE_PASSWORD: ${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}MIGRATION_PLATFORM_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/platformMIGRATION_INTAKE_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/intakeAPP_DB_ROLE: "true"DB_POOL_MAX: "40"SESSION_STORE: redisREDIS_REQUIRED: "true"REDIS_HOST: redisREDIS_PORT: "6379"REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}SEAWEEDFS_MASTER_URL: http://seaweedfs:9333SEAWEEDFS_FILER_URL: http://seaweedfs:8888SEAWEEDFS_VOLUME_URL: http://seaweedfs:8080ports:- "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"volumes:- "${UPLOADS_DATA:-uploads-data}:/app/uploads"depends_on:postgres:condition: service_healthyredis:condition: service_healthyseaweedfs:condition: service_healthyhealthcheck:test:- CMD- node- -e- "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"interval: 15stimeout: 5sretries: 3start_period: 120snetworks:- edge- backendpostgres:image: ${POSTGRES_IMAGE:-postgres:17-alpine}restart: unless-stoppedenvironment:POSTGRES_DB: platformPOSTGRES_USER: ${PGUSER:-postgres}POSTGRES_PASSWORD: ${PGPASSWORD:?PGPASSWORD is required}command: ["postgres", "-c", "max_connections=200"]volumes:- "${POSTGRES_DATA:-postgres-data}:/var/lib/postgresql/data"healthcheck:test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"']interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendredis:image: redis:7-alpinerestart: unless-stoppedenvironment:REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD is required}"]volumes:- "${REDIS_DATA:-redis-data}:/data"healthcheck:test: ["CMD-SHELL", "REDISCLI_AUTH=\"$${REDIS_PASSWORD}\" redis-cli ping | grep -q PONG"]interval: 10stimeout: 5sretries: 12start_period: 10snetworks:- backend# Master, volume server and filer together for a single-server installation.seaweedfs:image: chrislusf/seaweedfs:4.47restart: unless-stoppedworking_dir: /datacommand:- server- -ip=seaweedfs- -ip.bind=0.0.0.0- -dir=/data- -master.volumeSizeLimitMB=1024- -volume.max=0- -filervolumes:- "${SEAWEEDFS_DATA:-seaweedfs-data}:/data"configs:- source: filertarget: /etc/seaweedfs/filer.tomlhealthcheck:test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status && wget -q -O /dev/null http://127.0.0.1:8888/"]interval: 10stimeout: 5sretries: 12start_period: 30snetworks:- backendvolumes:postgres-data:redis-data:uploads-data:seaweedfs-data:networks:edge:backend:internal: trueconfigs:filer:content: |# Persist the file directory/index alongside the file contents in the data volume.[leveldb2]enabled = truedir = "/data/filerldb2"Your folder should now contain:
lunarpaper/├── .env└── compose.yamlKeep running commands here. The default project name is
lunarpaper. For another instance, keep theCOMPOSE_PROJECT_NAMEchosen under Running multiple instances. -
Validate the configuration.
Terminal window docker compose config --quiet--quietchecks the configuration without printing secrets. -
Pull the images and start Lunar Paper. The first download takes a couple of minutes.
Terminal window docker compose pullif ($LASTEXITCODE -eq 0) {docker compose up -d --waitif ($LASTEXITCODE -eq 0) {docker compose exec -T lunarpaper printenv APP_URL}}Terminal window docker compose pull &&docker compose up -d --wait &&docker compose exec -T lunarpaper printenv APP_URLOpen the URL printed after successful startup. If a command fails, resolve the error before continuing.
-
Initialize through setup. Open your
APP_URL(or/setupat that address), unlock it with yourSETUP_KEY, and review the detected database state. Start initialization explicitly in the wizard. Lunar Paper imports its bundled init files into the empty Platform and Intake databases, verifies both, and activates the release. Launch the application when setup completes, then sign in with your administrator email and password. See First-time setup.
If you see a maintenance page instead, follow Complete the database upgrade.
Keep a copy of .env with your backups, because it holds the keys that
decrypt your data. See Configuration and secrets.
First-time setup
Section titled “First-time setup”Official PostgreSQL creates its cluster and an empty platform database. It
becomes healthy before Lunar Paper’s application tables exist. The platform
container starts the protected setup page; it does not initialize application
schema just because the container started or the page was opened.
Keep the generated SETUP_KEY private. Unlocking setup grants an operator
session; the wizard’s explicit execution action imports the init files and
configures the restricted runtime accounts. The init files and retained
migrations are already in the platform image, so you do not download SQL or
mount an init directory. Readiness at /api/health/ready stays false until both
databases and the platform release have been verified.
If initialization is interrupted, reopen setup and inspect its status. Resume
only when setup offers recovery for that initialization. Unknown or partially
populated databases require investigation; do not delete volumes, rerun SQL or
generate a new .env to force setup through. Existing installations use the
upgrade flow.
Use a different port
Section titled “Use a different port”Only your chosen host port needs to be free. compose.yaml maps
HOST_PORT to port 3000 inside the application container.
For example, add these settings to .env for LAN access on port 3100:
HOST_PORT=3100HTTP_BIND_ADDRESS=192.168.1.20APP_URL=http://192.168.1.20:3100Use your server’s IP address. For access only from this computer, omit
HTTP_BIND_ADDRESS and use APP_URL=http://localhost:3100.
Keep the container’s PORT setting at 3000 so internal health checks still work.
Changing APP_URL alone does not change the published port.
Apply the settings with the startup command above, then open your chosen
APP_URL. Use the chosen host port in firewall rules and the checks below.
Check that it is running
Section titled “Check that it is running”The status command below should show lunarpaper, postgres, redis and
seaweedfs as healthy. Lunar Paper is ready when this returns HTTP 200.
These examples check a local installation. For LAN access or a different
host port, use your APP_URL followed by /api/health/ready instead:
(Invoke-WebRequest -UseBasicParsing http://localhost:3000/api/health/ready).StatusCodecurl -fsS http://localhost:3000/api/health/readycurl -fsS http://localhost:3000/api/health/readyChoose storage locations
Section titled “Choose storage locations”The generated .env includes these defaults:
POSTGRES_DATA=postgres-dataREDIS_DATA=redis-dataUPLOADS_DATA=uploads-dataSEAWEEDFS_DATA=seaweedfs-dataKeep them to use the existing Docker-managed volumes, such as
lunarpaper_postgres-data. Omitting a setting or leaving it empty uses the same
default. The project name defaults to lunarpaper; a custom COMPOSE_PROJECT_NAME
uses that name as the volume prefix instead.
To store a service’s data in your own folder, replace its value with an absolute
path on the Docker host, such as POSTGRES_DATA=/srv/lunarpaper/postgres on Linux
or UPLOADS_DATA=D:/LunarPaper/uploads on Windows. Use forward slashes for Windows
paths. Keep each service in its own folder, accessible to its container.
These are Docker bind mounts.
An arbitrary new volume name also needs a declaration in compose.yaml; the
four defaults above are already declared.
Choose storage locations before the first start. Changing a setting later points the container at different storage; it does not copy existing data. Back up and migrate the data before switching an existing installation. The backup guide distinguishes named volumes from custom folders.
Everyday commands
Section titled “Everyday commands”Run these in the lunarpaper folder containing compose.yaml and .env.
| To | Run |
|---|---|
Start, or apply a change to .env |
docker compose up -d --wait |
| Stop | docker compose stop |
| See container status | docker compose ps |
| Follow the log | docker compose logs -f lunarpaper |
| Remove the containers, keeping data | docker compose down |
Upgrade
Section titled “Upgrade”-
Read the new release notes and set
LUNAR_PAPER_VERSIONin.envto that published version. KeepLUNAR_PAPER_IMAGEas the repository name without a tag; Compose appends the selected version automatically. -
Keep
compose.yaml,.env, the project name and volumes. Review any configuration changes in the release notes. Do not change keys or passwords. -
Validate, then pull and recreate only the platform using the same configuration:
Terminal window docker compose config --quietdocker compose pull lunarpaperdocker compose up -d --wait --no-deps lunarpaperPostgreSQL stays on its existing image.
-
If a maintenance page appears, complete the database upgrade.
To use Lunar Paper from other computers, see HTTP on a local network.
Troubleshooting
Section titled “Troubleshooting”- A variable “is missing a value”. Open
.envand give that variable a value. - Lunar Paper keeps restarting. Run the log command from Everyday commands. The
usual causes are a password that breaks the rules above, or an
APP_URLthat does not start withhttp://orhttps://. - Port 3000 is in use. Set
HOST_PORT=3100in.envand put the same port inAPP_URL. See Use a different port. - Sign-in keeps returning to the sign-in page. Make sure the address in
your browser is exactly the same as
APP_URL. - An image download fails. Check that the release has been published and
that
LUNAR_PAPER_VERSIONnames a published version on GitHub Releases.
Start again from scratch
Section titled “Start again from scratch”Do this only on a new installation that has no data you want to keep. The
database is created with the passwords from your first .env, so if you make a
new .env, you also need to delete the old volumes:
docker compose down --volumesThen delete .env and continue from step 2.
