Skip to content

Install with Docker Compose and HTTPS

This guide is the Docker Compose installation using the same configuration plus Caddy, a web server that serves Lunar Paper over HTTPS and looks after the certificate for you. Use this guide if you want encrypted access across a network, a public domain, or the camera, microphone and clipboard features that browsers only allow over HTTPS.

Caddy listens on ports 80 and 443, and Lunar Paper is only reachable through Caddy. If you already run a reverse proxy, see Use your own reverse proxy instead.

Internal name Public domain
Example https://paper.internal https://paper.example.com
Certificate From Caddy’s own certificate authority Free, from Let’s Encrypt
Needs Each device trusts Caddy’s certificate once A public DNS record, and ports 80 and 443 open to the internet
CADDY_TLS_DIRECTIVE tls internal tls you@example.com

The .internal ending is reserved for private networks.

  • Install Docker, and make sure ports 80 and 443 are free.
  • Linux and macOS need OpenSSL for the secret generator.
  • Make the name point at the server with a DNS record. For a small setup, you can instead add a line like 192.168.1.20 paper.internal to the hosts file on each device (C:\Windows\System32\drivers\etc\hosts or /etc/hosts).
  • Choose the platform release in LUNAR_PAPER_VERSION. These examples target 0.8.1 in lunarpaper/lunarpaper.
Running multiple instances

Keep each installation in a separate folder with its own generated .env and storage. These commands explicitly use --project-name lunarpaper, which takes precedence over COMPOSE_PROJECT_NAME. For a fresh second instance, replace lunarpaper after every --project-name with a previously unused name, such as lunarpaper-test. Keep that name for future starts, backups and upgrades. Use a distinct COOKIE_NAMESPACE if instances share a browser hostname.

Each Caddy instance needs its own available host ports. If ports 80 and 443 are already occupied, use the basic Compose guide’s multiple-instance settings and put the additional instance behind your existing reverse proxy. Changing HOST_PORT does not change Caddy’s ports in this HTTPS configuration.

Default named volumes are separated by project name. Custom host storage folders must also be different. Reusing a project name or storage path can reuse an old database; it does not become fresh when you generate another .env.

  1. Create the installation folder.

    Terminal window
    mkdir lunarpaper
    cd lunarpaper

    Your private .env belongs in this folder. Step 4 creates the configuration files from the reference in these docs.

  2. Generate .env. Paste this into your terminal to create the file and generate its secrets. An existing .env is kept unchanged.

    Terminal window
    (
    set -euC
    if [ -e .env ] || [ -L .env ]; then
    printf '%s\n' 'Kept existing .env unchanged, including its settings and keys.'
    exit 0
    fi
    umask 077
    content=$(while IFS= read -r line; do
    if [[ "$line" == *'__LUNAR_PAPER_RANDOM_KEY__'* ]]; then
    key=$(openssl rand -hex 32) || exit 1
    [[ "$key" =~ ^[0-9a-f]{64}$ ]] || exit 1
    line="${line/__LUNAR_PAPER_RANDOM_KEY__/$key}"
    fi
    printf '%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 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=https://paper.internal
    # HTTPS certificate: use "tls internal" for your own network,
    # or "tls you@example.com" for a public domain.
    CADDY_TLS_DIRECTIVE=tls internal
    # 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__"}
    LUNAR_PAPER_ENV
    )
    printf '%s\n' "$content" > .env
    printf '%s\n' 'Created .env. Open it and 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 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=https://paper.internal
    # HTTPS certificate: use "tls internal" for your own network,
    # or "tls you@example.com" for a public domain.
    CADDY_TLS_DIRECTIVE=tls internal
    # 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=20ff4c29…c39d
    # Database passwords are generated independently for the usernames above.
    PGPASSWORD=71d00f39…4054
    APP_PLATFORM_PASSWORD=3f99528e…6a07
    APP_INTAKE_PASSWORD=f451519c…f779
    REDIS_PASSWORD=4ee9a68a…db28
    SESSION_SECRET=8a3abd55…c4d0
    ENCRYPTION_KEY=9ee2c8be…b117
    INTERNAL_API_TOKEN=c310a8aa…1bfa
    AI_ENCRYPTION_KEY=a884e455…f6e6
    MFA_ENCRYPTION_KEY=23fe94af…7fcb
    SSO_ENCRYPTION_KEY=64b365fc…4672
    DATA_SOURCE_ENCRYPTION_KEY=19af350e…cadc
    WORKFLOW_CREDENTIAL_ENCRYPTION_KEY=5a4e076d…5f5b
    PUBLISHER_CREDENTIAL_ENCRYPTION_KEY=3b61c262…9a14
    EMAIL_ENCRYPTION_KEY=9a1c50c0…2dce
    ADDRESS_PROVIDER_ENCRYPTION_KEY=ee02204d…135e
    EMAIL_TRACKING_SECRET=d295b034…7edc
    PDF_EXPORT_SECRET=e80d111f…8c3c
    FORM_FILE_ENCRYPTION_KEYS={"v1":"774453fa…fc60"}
  3. Edit your settings. Open .env with notepad .env or nano .env and set:

    APP_URL=https://paper.internal
    SYSTEM_ADMIN_USERNAME=you@example.com
    SYSTEM_ADMIN_PASSWORD=replace-with-your-password
    CADDY_TLS_DIRECTIVE=tls internal

    APP_URL must not include a port or end with a slash. For a public domain, use tls you@example.com with your own email address. 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 storage settings default to the existing named volumes. To choose host folders before the first start, see Choose storage locations.

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

  4. Generate the HTTPS files. Paste this into your terminal to create the files in release. Existing files are kept unchanged.

    Terminal window
    (
    set -euC
    if [ -e release ] && [ ! -d release ]; then
    printf '%s\n' 'release must be a folder.' >&2
    exit 1
    fi
    mkdir -p release
    if [ -e release/compose.yaml ] || [ -L release/compose.yaml ]; then
    printf '%s\n' 'Kept existing release/compose.yaml'
    else
    cat > release/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.
    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 or HTTPS.
    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
    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"
    - ./filer.toml:/etc/seaweedfs/filer.toml:ro
    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
    LUNAR_PAPER_FILE
    printf '%s\n' 'Created release/compose.yaml'
    fi
    if [ -e release/compose.https.yaml ] || [ -L release/compose.https.yaml ]; then
    printf '%s\n' 'Kept existing release/compose.https.yaml'
    else
    cat > release/compose.https.yaml <<'LUNAR_PAPER_FILE'
    # Use with compose.yaml; set APP_URL to https://your-internal-hostname.
    services:
    lunarpaper:
    environment:
    TRUST_PROXY_HOPS: "1"
    caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    environment:
    APP_URL: ${APP_URL:?APP_URL is required}
    CADDY_TLS_DIRECTIVE: ${CADDY_TLS_DIRECTIVE-tls internal}
    ports:
    - "80:80"
    - "443:443"
    - "443:443/udp"
    volumes:
    - ./Caddyfile:/etc/caddy/Caddyfile:ro
    - caddy-data:/data
    - caddy-config:/config
    depends_on:
    lunarpaper:
    condition: service_healthy
    networks:
    - edge
    volumes:
    caddy-data:
    caddy-config:
    LUNAR_PAPER_FILE
    printf '%s\n' 'Created release/compose.https.yaml'
    fi
    if [ -e release/filer.toml ] || [ -L release/filer.toml ]; then
    printf '%s\n' 'Kept existing release/filer.toml'
    else
    cat > release/filer.toml <<'LUNAR_PAPER_FILE'
    # Persist the file directory/index alongside the file contents in the data volume.
    [leveldb2]
    enabled = true
    dir = "/data/filerldb2"
    LUNAR_PAPER_FILE
    printf '%s\n' 'Created release/filer.toml'
    fi
    if [ -e release/Caddyfile ] || [ -L release/Caddyfile ]; then
    printf '%s\n' 'Kept existing release/Caddyfile'
    else
    cat > release/Caddyfile <<'LUNAR_PAPER_FILE'
    {$APP_URL} {
    {$CADDY_TLS_DIRECTIVE}
    encode zstd gzip
    reverse_proxy lunarpaper:3000
    header -Server
    }
    LUNAR_PAPER_FILE
    printf '%s\n' 'Created release/Caddyfile'
    fi
    )
    Optionally create the HTTPS files manually

    Create a release folder beside .env. Save each file below with its shown filename. Keep the ${VARIABLE} expressions; Compose fills them from .env.

    release/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.
    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 or HTTPS.
    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
    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"
    - ./filer.toml:/etc/seaweedfs/filer.toml:ro
    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
    release/compose.https.yaml
    # Use with compose.yaml; set APP_URL to https://your-internal-hostname.
    services:
    lunarpaper:
    environment:
    TRUST_PROXY_HOPS: "1"
    caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    environment:
    APP_URL: ${APP_URL:?APP_URL is required}
    CADDY_TLS_DIRECTIVE: ${CADDY_TLS_DIRECTIVE-tls internal}
    ports:
    - "80:80"
    - "443:443"
    - "443:443/udp"
    volumes:
    - ./Caddyfile:/etc/caddy/Caddyfile:ro
    - caddy-data:/data
    - caddy-config:/config
    depends_on:
    lunarpaper:
    condition: service_healthy
    networks:
    - edge
    volumes:
    caddy-data:
    caddy-config:
    release/filer.toml
    # Persist the file directory/index alongside the file contents in the data volume.
    [leveldb2]
    enabled = true
    dir = "/data/filerldb2"
    release/Caddyfile
    {$APP_URL} {
    {$CADDY_TLS_DIRECTIVE}
    encode zstd gzip
    reverse_proxy lunarpaper:3000
    header -Server
    }

    The HTTPS access file publishes Caddy on ports 80 and 443 and keeps the application port private. Use exactly one access file; do not combine HTTP and HTTPS. PostgreSQL uses the official postgres:17-alpine image.

  5. Validate the configuration without printing secrets.

    Terminal window
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml config --quiet
  6. Pull the images and start Lunar Paper.

    Terminal window
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml pull
    if ($LASTEXITCODE -eq 0) {
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml up -d --wait
    if ($LASTEXITCODE -eq 0) {
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml exec -T lunarpaper printenv APP_URL
    }
    }

    Startup prints your APP_URL when the containers are healthy. Install the certificate below before opening it. Resolve any startup error before continuing.

  7. Trust Caddy’s certificate. Skip this step if you use a public domain. For an internal name, copy the certificate out of the container:

    Terminal window
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml cp caddy:/data/caddy/pki/authorities/local/root.crt lunarpaper-root.crt

    Install lunarpaper-root.crt on every device that uses Lunar Paper. The file contains no secrets, so it is safe to copy around.

    Double-click the file, select Install Certificate, choose Local Machine, then Place all certificates in the following store, and pick Trusted Root Certification Authorities. Restart the browser.

    Firefox may use its own list of certificates. If it still shows a warning, import the file under Settings > Privacy & Security > Certificates > View Certificates > Authorities.

  8. Open your APP_URL and initialize. Unlock setup with SETUP_KEY, review the empty databases and explicitly start initialization. Wait for both databases and release activation to complete, then launch and sign in. See First-time setup.

If you see a maintenance page instead, follow Complete the database upgrade.

Run these in the lunarpaper folder. Keep both HTTPS configuration files on every command:

Terminal window
docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml ps
docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml logs -f lunarpaper
docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml stop
docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml up -d --wait
  1. Take a backup.

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

  3. Keep your configuration, project name, secrets and volumes. Validate, then replace only the platform container:

    Terminal window
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml config --quiet
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml pull lunarpaper
    docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml up -d --wait --no-deps lunarpaper
  4. If a maintenance page appears, complete the database upgrade.

docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml ps should show caddy on ports 80 and 443, and the other four containers as healthy. From a device that trusts the certificate, https://<your address>/api/health/ready should load without an error.

You can add HTTPS to an existing Docker Compose installation without losing data. In your existing lunarpaper folder:

  1. Take a backup and keep the same configuration, project name, secrets and volumes.
  2. Change APP_URL in .env to the https:// address and add the CADDY_TLS_DIRECTIVE line.
  3. Run the file-creation command in step 4 above to create the HTTPS configuration in release. If you customized your HTTP compose.yaml, carry those changes into release/compose.yaml, except for its HTTP ports mapping. Keep the original project name to reuse the same data volumes. If you used a custom COMPOSE_PROJECT_NAME, replace lunarpaper after --project-name in every HTTPS command with that name; the command-line option overrides .env.
  4. Run the validation, pull and startup commands above. They explicitly select the files in release; your old HTTP compose.yaml is no longer used. Use these HTTPS commands for future starts and upgrades too.
  5. Trust Caddy’s certificate as in step 7.

Afterwards, update any bookmarks and single sign-on callback addresses to the new address.

Set APP_URL to the IP address, such as https://192.168.1.20, and set CADDY_TLS_DIRECTIVE to tls internal. Then add this to the top of the Caddyfile:

{
default_sni 192.168.1.20
}

Browsers do not send a server name when they connect to an IP address, so this setting tells Caddy which certificate to use. Public certificate authorities do not issue certificates for private IP addresses.

  • Database login fails with 28P01. Existing volumes keep their original database passwords. Restore the matching .env; generating new secrets or recreating a container does not change stored passwords. For a separate fresh installation, use the multiple-instance guidance.
  • The certificate is not trusted. Install lunarpaper-root.crt on that device (step 7) and restart the browser.
  • Caddy cannot start because a port is in use. Another web server is using port 80 or 443. Stop it, or use your own reverse proxy.
  • A public domain gets no certificate. Check the DNS record, and check that ports 80 and 443 are reachable from the internet. Run docker compose --project-name lunarpaper --env-file .env -f release/compose.yaml -f release/compose.https.yaml logs caddy to see the error.
  • Some devices cannot reach the name. Check that the name works on that device, for example with ping paper.internal.

Keep the lunarpaper_caddy-data volume. It holds the certificates and Caddy’s certificate authority. If you lose it, every device has to trust a new certificate.