Menu
Penombre docs

Deployment

Deploy Penombre to production with Docker.

Requirements

  • A Linux server with Docker and Docker Compose installed
  • At least 1 GB of RAM (more if handling large files)
  • A domain name with DNS pointing to your server (for HTTPS)

Quick start

1. Download the environment file

curl -o .env https://raw.githubusercontent.com/orochibraru/penombre/refs/heads/main/.example.env

2. Edit the .env file

At minimum, set these values:

ORIGIN=https://cloud.example.com
AUTH_SECRET=$(openssl rand -hex 32)

See Environment variables for the full reference.

3. Create a compose.yaml

Penombre runs on SQLite by default, so the production stack is one container:

services:
  app:
    image: orochibraru/penombre:latest
    ports:
      - 3000:3000
    restart: unless-stopped
    volumes:
      - storage_data:/data
    env_file: .env
    # environment:
    #   - REDIS_URL=redis://redis:6379  # Uncomment to enable Redis caching

  # Optional: uncomment to enable distributed caching with Redis
  # redis:
  #     image: redis:7-alpine
  #     restart: unless-stopped
  #     volumes:
  #         - redis_data:/data
  #     healthcheck:
  #         test: [CMD, redis-cli, ping]
  #         interval: 5s
  #         timeout: 3s
  #         retries: 5

volumes:
  storage_data:
  # redis_data:

The storage_data volume holds both the database (/data/db) and your files (/data/storage).

4. Start the services

docker compose up -d

Penombre is now running on port 3000. Open it and the setup screen will ask you to create the administrator account — there are no default credentials.

Get to it before anyone else does. Until the first account exists, whoever reaches /auth/setup becomes the administrator, so do not leave a fresh instance exposed and unattended.

What happens on first start

  1. The app waits for the database to become reachable (up to 10 retries, 2 seconds apart). On SQLite the file is created if it doesn't exist.
  2. Drizzle ORM runs all pending database migrations automatically.
  3. If no users exist, every request redirects to the setup screen until an administrator has been created.
  4. The server starts listening on port 3000.

Migrations run automatically on every startup, including after image upgrades — no manual steps needed.

HTTPS

In production, place a reverse proxy in front of Penombre to terminate TLS. See the Reverse proxy guide for Caddy, Nginx, and Traefik examples.

When using a reverse proxy, you can remove the ports mapping from the app service and let the proxy reach it over a shared Docker network instead.

Background worker

Thumbnails, waveforms, scans and zip/trash/copy byte work run in a separate Go process, embedded in this same container by default. See Worker for running it as its own container instead.

Running on PostgreSQL (optional)

SQLite is the default and is the right answer for a single container. Reach for PostgreSQL only if you run multiple app instances against one dataset — a SQLite file can't be shared safely across containers.

Add a db service and point DATABASE_URL at it:

services:
  app:
    image: orochibraru/penombre:latest
    depends_on:
      db:
        condition: service_healthy
    ports:
      - 3000:3000
    restart: unless-stopped
    volumes:
      - storage_data:/data
    env_file: .env
    environment:
      - DATABASE_URL=postgresql://penombre:a-long-random-password@db:5432/penombre

  db:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=penombre
      - POSTGRES_PASSWORD=a-long-random-password
      - POSTGRES_DB=penombre
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: [CMD-SHELL, "sh -c 'pg_isready -U penombre -d penombre'"]
      interval: 1s
      timeout: 2s
      retries: 10
      start_period: 3s

volumes:
  postgres_data:
  storage_data:

Don't publish a 5432 port mapping unless you actually need external database access. Migrations for both dialects ship in the image and run on startup, so nothing else changes.

Persistent storage

Penombre stores your files and — on the default SQLite setup — its database under /data inside the container, backed by the storage_data Docker volume. On PostgreSQL the database lives in the postgres_data volume instead.

Volumes persist across container restarts and image upgrades. To inspect them:

docker volume inspect penombre_storage_data

Health check

The container includes a built-in health check that probes the app every 30 seconds. Docker marks the container as unhealthy after 3 consecutive failures.

docker inspect --format='{{.State.Health.Status}}' penombre-app-1

Updating

Image tags

TagWhat it is
latestThe last stable release.
X.Y.ZOne stable release, pinned.
canaryEvery change merged to main, once its end-to-end tests pass.
X.Y.Z-canary.NOne canary build, pinned.

A stable release is a canary that has been promoted as is, not a new build. Run canary to get changes days before they are released, on an instance you can afford to restore from backup.

The update banner watches one of these two streams; RELEASE_CHANNEL=stable or canary (see env vars), defaulting to whichever one the image you are already running belongs to. Run canary and it tells you about X.Y.Z-canary.N builds too, not just the next latest.

1. Back up your instance

Back up your database (pg_dump for Postgres, or a copy of the .sqlite file) and your STORAGE_PATH directory, using whatever tooling you already run for the rest of your server.

With encryption on, back up ENCRYPTION_KEY too, but store it apart from those backups: a backup that holds its own key protects nothing, and a backup without any copy of the key restores nothing.

2. Pull the latest image and recreate the container

docker compose pull
docker compose up -d

Database migrations run automatically on startup — no manual steps required.

Building from source

If you prefer to build the Docker image yourself:

git clone https://github.com/orochibraru/penombre.git
cd penombre
docker buildx bake app

This builds a local image tagged orochibraru/penombre:latest. TAG is a bake variable, so pass it as an environment variable to add a second tag:

TAG=v1.0.0 docker buildx bake app

docker buildx bake docs builds the documentation site image the same way, and docker buildx bake with no target builds both.

For multi-platform builds (amd64 + arm64):

docker buildx bake app --set app.platform=linux/amd64,linux/arm64

Resource tuning

Upload size

BODY_SIZE_LIMIT is set to Infinity in the Docker image, so file uploads are not limited by the app itself. To enforce a limit, configure it at the reverse proxy level (e.g. client_max_body_size in Nginx).

Database connections (PostgreSQL only)

On SQLite there is no pool to tune. On PostgreSQL the default connection pool is tuned for small to medium deployments:

SettingValueDescription
max20Maximum pool size
idleTimeout30sClose idle connections after 30s
maxLifetime1800sRecycle connections every 30 minutes
connectionTimeout10sFail fast if DB is unreachable

PostgreSQL's default max_connections is 100. If you run multiple replicas, scale the pool size down accordingly.

This guide lives in the project repo: edit it there, and this page follows within a day.