Skip to content

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.

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:

Terminal window
docker version
docker compose version

Use PowerShell for the commands in this guide.

docker 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. Use http://localhost:3000 if 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-test
APP_URL=http://localhost:3100
HTTP_BIND_ADDRESS=127.0.0.1
HOST_PORT=3100
COOKIE_NAMESPACE=lunarpaper_test

Choose 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-data
REDIS_DATA=redis-data
UPLOADS_DATA=uploads-data
SEAWEEDFS_DATA=seaweedfs-data

COMPOSE_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.

  1. Create the installation folder.

    Terminal window
    mkdir lunarpaper
    cd lunarpaper

    Run the remaining commands here. Keep .env and compose.yaml together.

  2. Generate .env. Paste this into your terminal to create the file and generate its secrets. An existing .env is 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:3000
    APP_URL=http://localhost:3000
    # HTTP access. Use your server's LAN IP for access from other computers.
    HTTP_BIND_ADDRESS=127.0.0.1
    HOST_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-data
    REDIS_DATA=redis-data
    UPLOADS_DATA=uploads-data
    SEAWEEDFS_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=postgres
    APP_PLATFORM_USER=app_platform
    APP_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.com
    SYSTEM_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.'
    }
    Optionally create .env manually

    Create .env in your installation folder and paste this template. Replace every shortened value containing … with a separately generated 64-character hexadecimal secret, including the value inside FORM_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:3000
    APP_URL=http://localhost:3000
    # HTTP access. Use your server's LAN IP for access from other computers.
    HTTP_BIND_ADDRESS=127.0.0.1
    HOST_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-data
    REDIS_DATA=redis-data
    UPLOADS_DATA=uploads-data
    SEAWEEDFS_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=postgres
    APP_PLATFORM_USER=app_platform
    APP_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.com
    SYSTEM_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…6096
    APP_PLATFORM_PASSWORD=330060eb…2c70
    APP_INTAKE_PASSWORD=6cd87778…5958
    REDIS_PASSWORD=a5bf071e…70a7
    SESSION_SECRET=abf73647…b717
    ENCRYPTION_KEY=1c91c9a9…0661
    INTERNAL_API_TOKEN=ac08c5eb…24f9
    AI_ENCRYPTION_KEY=6b70d6de…a4f9
    MFA_ENCRYPTION_KEY=e0e02f3e…15be
    SSO_ENCRYPTION_KEY=73df63d5…df83
    DATA_SOURCE_ENCRYPTION_KEY=2319ed4c…137f
    WORKFLOW_CREDENTIAL_ENCRYPTION_KEY=ac2cfe15…5616
    PUBLISHER_CREDENTIAL_ENCRYPTION_KEY=a19370a3…c8de
    EMAIL_ENCRYPTION_KEY=05e86d1d…0344
    ADDRESS_PROVIDER_ENCRYPTION_KEY=ef542acf…1152
    EMAIL_TRACKING_SECRET=55ba1312…adaa
    PDF_EXPORT_SECRET=927c1659…7f0e
    FORM_FILE_ENCRYPTION_KEYS={"v1":"ee7f9ce6…6432"}
  3. Edit your settings. Open .env:

    Terminal window
    notepad .env

    Save the file after editing.

    Set your browser address, administrator email and password:

    APP_URL=http://localhost:3000
    SYSTEM_ADMIN_USERNAME=you@example.com
    SYSTEM_ADMIN_PASSWORD=replace-with-your-password

    APP_URL must 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.1 and APP_URL=http://localhost:3000. For access from other computers, change HTTP_BIND_ADDRESS to an IP address actually assigned to your server and use the same address in APP_URL. Example LAN addresses in this guide are placeholders; do not copy them unless they match your server. HOST_PORT defaults to 3000; if you change it, update the port in APP_URL too.

    To customize database usernames before the first start, see Database usernames.

  4. Generate compose.yaml. Paste this into your terminal to create the file. An existing compose.yaml is 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: lunarpaper
    services:
    lunarpaper:
    image: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}
    restart: unless-stopped
    environment:
    NODE_ENV: production
    PORT: "3000"
    PROCESS_ROLE: all
    CLUSTER_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: postgres
    PGPORT: "5432"
    PGDATABASE: platform
    PGUSER: ${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/platform
    INTAKE_DATABASE_URL: postgresql://${APP_INTAKE_USER:-app_intake}:${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}@postgres:5432/intake
    APP_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/platform
    MIGRATION_INTAKE_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/intake
    APP_DB_ROLE: "true"
    DB_POOL_MAX: "40"
    SESSION_STORE: redis
    REDIS_REQUIRED: "true"
    REDIS_HOST: redis
    REDIS_PORT: "6379"
    REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
    SEAWEEDFS_MASTER_URL: http://seaweedfs:9333
    SEAWEEDFS_FILER_URL: http://seaweedfs:8888
    SEAWEEDFS_VOLUME_URL: http://seaweedfs:8080
    ports:
    - "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"
    volumes:
    - "${UPLOADS_DATA:-uploads-data}:/app/uploads"
    depends_on:
    postgres:
    condition: service_healthy
    redis:
    condition: service_healthy
    seaweedfs:
    condition: service_healthy
    healthcheck:
    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: 15s
    timeout: 5s
    retries: 3
    start_period: 120s
    networks:
    - edge
    - backend
    postgres:
    image: ${POSTGRES_IMAGE:-postgres:17-alpine}
    restart: unless-stopped
    environment:
    POSTGRES_DB: platform
    POSTGRES_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: 10s
    timeout: 5s
    retries: 12
    start_period: 30s
    networks:
    - backend
    redis:
    image: redis:7-alpine
    restart: unless-stopped
    environment:
    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: 10s
    timeout: 5s
    retries: 12
    start_period: 10s
    networks:
    - backend
    # Master, volume server and filer together for a single-server installation.
    seaweedfs:
    image: chrislusf/seaweedfs:4.47
    restart: unless-stopped
    working_dir: /data
    command:
    - server
    - -ip=seaweedfs
    - -ip.bind=0.0.0.0
    - -dir=/data
    - -master.volumeSizeLimitMB=1024
    - -volume.max=0
    - -filer
    volumes:
    - "${SEAWEEDFS_DATA:-seaweedfs-data}:/data"
    configs:
    - source: filer
    target: /etc/seaweedfs/filer.toml
    healthcheck:
    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: 10s
    timeout: 5s
    retries: 12
    start_period: 30s
    networks:
    - backend
    volumes:
    postgres-data:
    redis-data:
    uploads-data:
    seaweedfs-data:
    networks:
    edge:
    backend:
    internal: true
    configs:
    filer:
    content: |
    # Persist the file directory/index alongside the file contents in the data volume.
    [leveldb2]
    enabled = true
    dir = "/data/filerldb2"
    '@
    }
    foreach ($file in $files.GetEnumerator()) {
    $destination = Join-Path . $file.Key
    if (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"
    }
    }
    Optionally create compose.yaml manually

    Create compose.yaml beside .env and 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: lunarpaper
    services:
    lunarpaper:
    image: ${LUNAR_PAPER_IMAGE:-ghcr.io/lunarpaper/platform}:${LUNAR_PAPER_VERSION:?LUNAR_PAPER_VERSION is required}
    restart: unless-stopped
    environment:
    NODE_ENV: production
    PORT: "3000"
    PROCESS_ROLE: all
    CLUSTER_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: postgres
    PGPORT: "5432"
    PGDATABASE: platform
    PGUSER: ${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/platform
    INTAKE_DATABASE_URL: postgresql://${APP_INTAKE_USER:-app_intake}:${APP_INTAKE_PASSWORD:?APP_INTAKE_PASSWORD is required}@postgres:5432/intake
    APP_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/platform
    MIGRATION_INTAKE_DATABASE_URL: postgresql://${PGUSER:-postgres}:${PGPASSWORD:?PGPASSWORD is required}@postgres:5432/intake
    APP_DB_ROLE: "true"
    DB_POOL_MAX: "40"
    SESSION_STORE: redis
    REDIS_REQUIRED: "true"
    REDIS_HOST: redis
    REDIS_PORT: "6379"
    REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
    SEAWEEDFS_MASTER_URL: http://seaweedfs:9333
    SEAWEEDFS_FILER_URL: http://seaweedfs:8888
    SEAWEEDFS_VOLUME_URL: http://seaweedfs:8080
    ports:
    - "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"
    volumes:
    - "${UPLOADS_DATA:-uploads-data}:/app/uploads"
    depends_on:
    postgres:
    condition: service_healthy
    redis:
    condition: service_healthy
    seaweedfs:
    condition: service_healthy
    healthcheck:
    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: 15s
    timeout: 5s
    retries: 3
    start_period: 120s
    networks:
    - edge
    - backend
    postgres:
    image: ${POSTGRES_IMAGE:-postgres:17-alpine}
    restart: unless-stopped
    environment:
    POSTGRES_DB: platform
    POSTGRES_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: 10s
    timeout: 5s
    retries: 12
    start_period: 30s
    networks:
    - backend
    redis:
    image: redis:7-alpine
    restart: unless-stopped
    environment:
    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: 10s
    timeout: 5s
    retries: 12
    start_period: 10s
    networks:
    - backend
    # Master, volume server and filer together for a single-server installation.
    seaweedfs:
    image: chrislusf/seaweedfs:4.47
    restart: unless-stopped
    working_dir: /data
    command:
    - server
    - -ip=seaweedfs
    - -ip.bind=0.0.0.0
    - -dir=/data
    - -master.volumeSizeLimitMB=1024
    - -volume.max=0
    - -filer
    volumes:
    - "${SEAWEEDFS_DATA:-seaweedfs-data}:/data"
    configs:
    - source: filer
    target: /etc/seaweedfs/filer.toml
    healthcheck:
    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: 10s
    timeout: 5s
    retries: 12
    start_period: 30s
    networks:
    - backend
    volumes:
    postgres-data:
    redis-data:
    uploads-data:
    seaweedfs-data:
    networks:
    edge:
    backend:
    internal: true
    configs:
    filer:
    content: |
    # Persist the file directory/index alongside the file contents in the data volume.
    [leveldb2]
    enabled = true
    dir = "/data/filerldb2"

    Your folder should now contain:

    lunarpaper/
    ├── .env
    └── compose.yaml

    Keep running commands here. The default project name is lunarpaper. For another instance, keep the COMPOSE_PROJECT_NAME chosen under Running multiple instances.

  5. Validate the configuration.

    Terminal window
    docker compose config --quiet

    --quiet checks the configuration without printing secrets.

  6. Pull the images and start Lunar Paper. The first download takes a couple of minutes.

    Terminal window
    docker compose pull
    if ($LASTEXITCODE -eq 0) {
    docker compose up -d --wait
    if ($LASTEXITCODE -eq 0) {
    docker compose exec -T lunarpaper printenv APP_URL
    }
    }

    Open the URL printed after successful startup. If a command fails, resolve the error before continuing.

  7. Initialize through setup. Open your APP_URL (or /setup at that address), unlock it with your SETUP_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.

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.

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=3100
HTTP_BIND_ADDRESS=192.168.1.20
APP_URL=http://192.168.1.20:3100

Use 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.

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:

Terminal window
(Invoke-WebRequest -UseBasicParsing http://localhost:3000/api/health/ready).StatusCode

The generated .env includes these defaults:

POSTGRES_DATA=postgres-data
REDIS_DATA=redis-data
UPLOADS_DATA=uploads-data
SEAWEEDFS_DATA=seaweedfs-data

Keep 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.

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
  1. Take a backup.

  2. Read the new release notes and set LUNAR_PAPER_VERSION in .env to that published version. Keep LUNAR_PAPER_IMAGE as the repository name without a tag; Compose appends the selected version automatically.

  3. Keep compose.yaml, .env, the project name and volumes. Review any configuration changes in the release notes. Do not change keys or passwords.

  4. Validate, then pull and recreate only the platform using the same configuration:

    Terminal window
    docker compose config --quiet
    docker compose pull lunarpaper
    docker compose up -d --wait --no-deps lunarpaper

    PostgreSQL stays on its existing image.

  5. If a maintenance page appears, complete the database upgrade.

To use Lunar Paper from other computers, see HTTP on a local network.

  • A variable “is missing a value”. Open .env and 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_URL that does not start with http:// or https://.
  • Port 3000 is in use. Set HOST_PORT=3100 in .env and put the same port in APP_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_VERSION names a published version on GitHub Releases.

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:

Terminal window
docker compose down --volumes

Then delete .env and continue from step 2.