Skip to content

Repository files navigation

PicHaus

A self-hosted, collaborative photo album platform built for photography clubs. Photographers upload via a share link — no account required. Owners manage albums, cover photos, share links, and API access from a clean web UI.


Table of Contents

  1. Features
  2. Tech Stack
  3. Quick Start
  4. Docker Deployment
  5. Environment Variables
  6. First-Time Setup
  7. User Guide
  8. OAuth and Registration
  9. Invites and Password Resets
  10. Branding
  11. External API Reference
  12. Authentication
  13. Storage
  14. Database

Features

  • Collaborative albums — invite collaborators or share an upload link; anyone with the link can upload without an account
  • EXIF metadata — camera model, lens, focal length, ISO, aperture, shutter speed, date taken — extracted automatically on upload
  • Justified photo grid — responsive masonry layout via Immich's WASM-accelerated justified-layout engine
  • Blurhash placeholders — progressive image loading with smooth fade-in
  • Duplicate detection — SHA-256 file hashing prevents uploading the same photo twice to the same album
  • Album cover cropper — interactive 16:9 cropper with move, resize, rule-of-thirds guide, and live preview
  • Share links — generate view or upload links per album, with optional password and expiry
  • Share groups — bundle multiple albums under one share link
  • Branding and theming — customize site name, accent color, logos, album/share-group headers, and upload-page messages
  • Instagram handles — photographers can attach their Instagram username, shown on photos
  • User avatars — upload a cropped profile photo or import one automatically from OAuth providers
  • Favorites — mark photos as favorites while browsing a share link; selections persist per album context and survive page refresh
  • Statistics dashboard — top cameras, lenses, aperture/ISO/shutter distributions, monthly activity timeline
  • Google and Microsoft sign-in — optional OAuth login alongside email/password and passkeys
  • Passkeys & security keys — passwordless login via WebAuthn/FIDO2 (Face ID, Touch ID, Windows Hello, YubiKey, etc.)
  • External API — scoped API tokens for integrating PicHaus with external sites or workflows
  • Fully self-hosted — Docker image, PostgreSQL, local file storage

Tech Stack

Layer Technology
Framework Nuxt 4 (Vue 3, Nitro server)
Styling TailwindCSS + CSS custom properties
ORM / DB Drizzle ORM + PostgreSQL
Image processing Sharp
EXIF parsing exifr
Blurhash blurhash
Photo layout @immich/justified-layout-wasm
Password hashing Argon2id
Passkeys / Security Keys @simplewebauthn/server + @simplewebauthn/browser
Runtime Bun

Quick Start

Prerequisites

  • Bun ≥ 1.0
  • PostgreSQL 14+
# Clone and install
git clone https://github.com/ChokunPlayZ/PicHaus.git
cd PicHaus
bun install

# Configure environment
cp .env.example .env
# Edit .env — set DATABASE_URL and AUTH_SECRET

# Start development server
bun dev

The app runs at http://localhost:3000. Database migrations run automatically on startup. On first visit you are redirected to /setup to create the admin account.


Docker Deployment

docker build -t pichaus .

docker run -d \
  --name pichaus \
  -p 3000:3000 \
  -e DATABASE_URL="postgresql://user:pass@host:5432/pichaus" \
  -e AUTH_SECRET="your-random-32-char-secret-here" \
  -e STORAGE_DIR="/data/uploads" \
  -v pichaus-storage:/data/uploads \
  pichaus

Note: DATABASE_URL is only needed at runtime — the build step has no database dependency.

docker-compose example

services:
  pichaus:
    image: pichaus
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://pichaus:secret@db:5432/pichaus
      AUTH_SECRET: replace-with-32-plus-char-random-string
      STORAGE_DIR: /data/uploads
      MAX_FILE_SIZE_MB: "20"
    volumes:
      - uploads:/data/uploads
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: pichaus
      POSTGRES_USER: pichaus
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  uploads:
  pgdata:

Environment Variables

Variable Required Default Description
DATABASE_URL Yes PostgreSQL connection string
AUTH_SECRET Yes (prod) dev fallback HMAC secret for session tokens — minimum 32 characters
STORAGE_DRIVER No local Storage backend: local or s3
STORAGE_DIR No storage/uploads Absolute or relative path where uploaded files are stored
ASSET_DELIVERY No proxy Asset delivery mode: proxy streams through PicHaus; redirect sends clients to S3 after access checks
S3_BUCKET When STORAGE_DRIVER=s3 S3-compatible bucket name
S3_REGION When STORAGE_DRIVER=s3 us-east-1 S3 signing region
S3_ENDPOINT No AWS S3 endpoint Custom S3-compatible endpoint, e.g. MinIO or R2
S3_ACCESS_KEY_ID When STORAGE_DRIVER=s3 S3 access key ID; AWS_ACCESS_KEY_ID is also accepted
S3_SECRET_ACCESS_KEY When STORAGE_DRIVER=s3 S3 secret access key; AWS_SECRET_ACCESS_KEY is also accepted
S3_SESSION_TOKEN No Temporary credential session token; AWS_SESSION_TOKEN is also accepted
S3_PREFIX No Optional object key prefix inside the bucket
S3_FORCE_PATH_STYLE No endpoint-aware Use path-style URLs; defaults to true with S3_ENDPOINT and false for AWS S3
S3_PUBLIC_BASE_URL No Public bucket or CDN base URL used by ASSET_DELIVERY=redirect; omit to use short-lived presigned URLs
S3_PRESIGNED_URL_TTL_SECONDS No 300 Lifetime for private-bucket presigned redirects, clamped to 1 second through 7 days
MAX_FILE_SIZE_MB No 10 Maximum upload size per file in megabytes
AUTO_COMPRESS_LIMIT_MB No 15 File size in MB above which ANY uploaded image is compressed, regardless of origin/editing software
FRESH_COMPRESS_LIMIT_MB No 4 File size in MB above which fresh-off-camera images (no editing software) are compressed
AUTO_COMPRESS_FORCE No false Set to true to force auto-compression on all files, bypassing editing software detection
AUTO_COMPRESS_RATIO_MB_PER_MP No 0.5 Ratio of file size (in MB) to image resolution (in Megapixels) above which JPEGs are compressed
AUTO_COMPRESS_MAX_DIMENSION No 4000 Maximum width or height dimension (in pixels) to resize compressed images to
AUTO_COMPRESS_QUALITY No 88 Compression quality for JPEGs/PNGs (1 to 100)
NODE_ENV No development Set to production in production deployments
WEBAUTHN_RP_ID No localhost Passkey relying-party ID — must match the domain users visit (no port, no protocol)
WEBAUTHN_RP_NAME No PicHaus Human-readable relying-party name shown by the browser during passkey registration
WEBAUTHN_ORIGIN No http://localhost:3000 Exact origin in the browser address bar — must include protocol and port if non-standard
GOOGLE_CLIENT_ID No OAuth 2.0 client ID from Google Cloud Console — enables Google Sign-In when set
GOOGLE_CLIENT_SECRET No OAuth 2.0 client secret — required alongside GOOGLE_CLIENT_ID
MICROSOFT_CLIENT_ID No OAuth 2.0 application/client ID from Microsoft Entra ID — enables Microsoft Sign-In when set
MICROSOFT_CLIENT_SECRET No OAuth 2.0 client secret — required alongside MICROSOFT_CLIENT_ID

Security: AUTH_SECRET must be a random string of at least 32 characters. In production the server will refuse to start without it.

Passkeys in production: Set WEBAUTHN_RP_ID to your bare domain (e.g. photos.example.com), WEBAUTHN_ORIGIN to https://photos.example.com, and WEBAUTHN_RP_NAME to whatever label you want users to see in their authenticator. The three values must match exactly — mismatches cause silent passkey registration or login failures.

Google Sign-In: Create an OAuth 2.0 credential in Google Cloud Console, add your origin to the authorised JavaScript origins, and add <origin>/api/v1/auth/google/callback as an authorised redirect URI. Set both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to enable the feature — Google Sign-In is hidden from the UI when GOOGLE_CLIENT_ID is absent.

Microsoft Sign-In: Create an app registration in Microsoft Entra ID, add <origin>/api/v1/auth/microsoft/callback as a web redirect URI, and set both MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET. The app requests openid email profile User.Read. Admins can enable the button and optionally set a tenant ID from AdminSettings; common is used when no tenant is configured.

Auto-Compression and Resizing: PicHaus automatically compresses and resizes uploaded photos to keep storage footprint and load times low, while preserving EXIF metadata on the saved files:

  • Resizing: Resizes files exceeding AUTO_COMPRESS_MAX_DIMENSION (default: 4000px on the longest edge) using standard quality parameters (AUTO_COMPRESS_QUALITY default: 88).
  • Always-Compress Threshold: Any uploaded image exceeding AUTO_COMPRESS_LIMIT_MB (default: 15MB) is always compressed.
  • Size-to-Resolution Ratio: If a JPEG is larger than necessary for its actual resolution (Megapixels), it gets compressed. By default, if the ratio of file size (in MB) to image resolution (in Megapixels) exceeds AUTO_COMPRESS_RATIO_MB_PER_MP (default: 0.5 MB/MP), it is compressed. For example, a 12MP photo that is 9MB has a ratio of 0.75, which triggers compression.
  • Fresh Camera vs Edited Photos: Edited photos exported from software (e.g. Lightroom, Photoshop) are respected as-is, unless they exceed the always-compress threshold or the ratio check. Direct-from-camera photos without editor tags are compressed if they exceed FRESH_COMPRESS_LIMIT_MB (default: 4MB) or 15 megapixels.

First-Time Setup

  1. Navigate to http://your-host:3000 — you are automatically redirected to /setup
  2. Enter a name, email address, and password (minimum 8 characters) for the admin account
  3. Click Complete Setup — you are redirected to /login
  4. Sign in with the credentials you just created

The setup endpoint is permanently disabled once the first account exists.


User Guide

Albums

Albums are the primary organisational unit. Each album has a title, optional description, optional event date, tags, visibility (public/private), and an optional cover photo.

Creating an album

  1. Go to Albums → click Create Album
  2. Fill in the title and optional fields
  3. Upload photos directly after creation

Album views

  • Grid — card layout with cover photo thumbnails
  • Timeline — albums grouped by event date (month/year)

Searching and filtering

The album list supports:

  • Full-text search across title, description, and owner name
  • Tag search (text input or click a tag chip)
  • Combined tag + text filters

Album permissions

Role Can view Can upload Can edit metadata Can manage share links
Owner
Admin collaborator
Editor collaborator
Viewer (share link)
Upload link user

Cover photo

Open an album → click the cover area → select any photo → crop using the 16:9 cropper:

  • Drag inside the selection to move it
  • Drag the corner handles to resize (ratio is locked)
  • Use the live preview to verify the result before saving

Album branding

Album owners can set a theme preset, custom theme values, header logo text, or a logo image. These settings are used on the album page, public share views, and upload pages. Logos are uploaded once and can be reused across albums, share groups, OAuth buttons, and site branding.

Batch operations

In an album, enter selection mode (checkbox icon or long-press on mobile) to:

  • Click a photo to toggle it; Shift+click to range-select from the last touched photo
  • Cmd/Ctrl+click to toggle an individual photo without clearing the selection
  • Delete selected photos
  • Download selected photos as a ZIP

Photos

The Photos page shows every photo across all your albums in a single justified grid with infinite scroll.

Filtering

  • Camera model
  • Lens
  • Start date (by date taken)

Photo viewer

Click any photo to open the full-screen viewer:

  • Navigate with arrow keys or swipe
  • View EXIF data (camera, lens, focal length, ISO, aperture, shutter speed)
  • Download the original file
  • Share via the native share sheet (mobile)
  • Adjacent photos are preloaded for smooth navigation

EXIF metadata

On upload, the following fields are extracted automatically from the file: cameraModel, lens, focalLength, iso, aperture, shutterSpeed, dateTaken

These can be manually edited from the photo context menu (right-click or long-press).


Share Links

Every album can have multiple share links. Links are accessed at /v/<token>.

Types

Type Description
view Read-only access — visitors can browse and download photos
upload Visitors can upload photos to the album (creates a guest account)

Options

  • Label — a human-readable name for the link (e.g. "Club members")
  • Password — optional; visitors must enter the password before accessing
  • Expiry date — optional; link becomes invalid after this date
  • Show metadata — toggle whether EXIF data is visible to share link visitors
  • Upload message — optional note shown on upload links before photographers submit files

Managing links

Go to Share Links in the sidebar to see all links across all albums, with view counts, type badges, and expiry status. Links can be edited (label, expiry, password, metadata visibility) or deleted from this page.


Share Groups

A share group bundles multiple albums under a single share link. Visitors who access the group link see a gallery of all albums in the group and can open individual albums from there.

Creating a share group

From an album's share link dialog, choose Create Share Group and add multiple albums. A single token is generated for the whole group.

Group share links support the same password and expiry options as individual album links.

Share groups can also have their own tags, theme, logo text, and logo image. Group branding is shown on the group share page and inherited by upload flows for group links.


Favorites

While browsing a share link (/v/<token>), visitors can mark photos as favorites. Favorites are:

  • Toggled by clicking the heart/star icon on any photo
  • Persisted in localStorage keyed by token and album, so they survive a page refresh
  • Context-aware — switching between albums in a share group saves and restores each album's favorites separately
  • Stored locally in the browser only (not synced to the server)

Guest Upload Flow

This is designed for photography club events: the club owner creates an upload share link, distributes it to photographers, and photographers upload directly without creating an account.

From the photographer's perspective

  1. Open the share link URL (/v/<token>) and click Upload Photos
  2. If password-protected, enter the password
  3. Enter a display name and optionally an email and Instagram handle
  4. Drag and drop photos onto the upload zone, or click to browse — a full-page overlay activates when files are dragged over the window
  5. A per-file thumbnail queue appears showing each file's status: pending → hashing → uploading → done / duplicate / error
  6. An overall progress bar tracks the batch; a summary (N uploaded · N duplicates skipped · N failed) appears on completion
  7. Click Add more to queue additional files, Clear all to reset, or Upload More Photos to start a new batch

Uploads use resumable chunks. If the browser reconnects while the same file hash is still staged on the server, PicHaus resumes from the next expected byte instead of starting from zero. Chunk sessions are stored under STORAGE_DIR/resumable and are promoted to the configured storage backend after the final chunk is received.

Account behaviour

  • No email provided → an anonymous guest account is created
  • New email → a new account is created with that email
  • Existing email, no password → authenticated as the existing guest account
  • Existing email with password → must provide the account password to authenticate

Uploaded photos are credited to the photographer's account and their Instagram handle (if provided) is shown on their photos.


Statistics

The Statistics page (/statistics) shows aggregated data across all your albums:

  • Total photos and total albums counters
  • Storage used
  • Top cameras — bar chart of the 5 most-used camera models by shot count
  • Top lenses — same for lens models
  • Technical stats — frequency tables for aperture, ISO, shutter speed, focal length
  • Activity timeline — line chart of photos uploaded per month

API Tokens

API tokens allow external services (personal websites, scripts, integrations) to query your albums and photos via the External API.

Creating a token

  1. Go to API Keys in the sidebar
  2. Enter a token name (e.g. "My Portfolio Site")
  3. Select the scopes you need (photos:read, albums:read)
  4. Click Create Token — copy the token immediately, it is shown only once

Scopes

Scope Access
albums:read List albums, get album detail
photos:read List photos in an album, get random photos

Tokens can be revoked at any time from the API Keys page.


Settings

The Settings page lets each user update profile details, Instagram handle, theme preference, passkeys, and profile photo. Avatar uploads are cropped in the browser, saved as WebP, and shown on album, share, collaborator, and photographer views.

OAuth sign-ins can import provider profile photos on first login. A manually uploaded avatar is never overwritten by Google or Microsoft.


Admin Panel

Accessible under /admin/* for accounts with the ADMIN role.

  • Users — view all users, edit name/email/Instagram/role, promote or demote admins, impersonate a user for troubleshooting, merge duplicate accounts, and delete users
  • Invites — create invite links and password-reset links, review usage, and revoke unused links
  • Settings — configure site name, accent color, site logo, public registration, Google OAuth, Microsoft OAuth, OAuth button text, OAuth button logos, and Google Workspace domain restrictions
  • Logos — upload and delete reusable logo assets, with usage badges for site, OAuth, album, and share-group references
  • Status — inspect deployment health and configured services

The app prevents demoting the last remaining admin.


OAuth and Registration

Email/password login is always available for existing accounts. Public self-registration is controlled by AdminSettingsAllow public registration.

Google and Microsoft OAuth have two layers:

  1. Environment variables provide the provider credentials.
  2. Admin settings decide whether the login buttons are shown and how they are labeled.

For Google Workspace installs, set an allowed domain in admin settings to require Google's hosted-domain claim (hd) to match that domain. Holding Shift during Google sign-in can bypass that restriction only when the admin setting is enabled.

OAuth users are matched by provider ID or email. On first successful OAuth login, PicHaus creates a user if one does not already exist, stores the provider ID, and imports a profile photo when available.


Invites and Password Resets

Admins can create invite tokens and password-reset tokens from /admin/invites.

  • Invite tokens allow a new user to create an account even when public registration is disabled.
  • Password-reset tokens are tied to an existing user and expire at the configured time.
  • Tokens are single-use; once redeemed, usedAt is recorded and the token cannot be reused.

Invite links are opened at /invite/<token>.


Branding

PicHaus branding is layered:

  • Site settings control the global site name, accent color, and navbar/logo shown across the app.
  • OAuth button settings control custom text and optional logo assets for Google and Microsoft sign-in buttons.
  • Album settings control the logo text/image and theme for that album's private page, public share page, and upload page.
  • Share group settings control the logo text/image and theme for grouped public views.

Logo files are stored in the configured storage backend under logos/ and served through /api/assets/logo/<id>.


External API Reference

All external endpoints require:

Authorization: Bearer <api_token>

Responses are JSON with a top-level success: true field. Timestamps are Unix seconds (integers).


GET /api/external/albums

Scope: albums:read

List albums owned by the token owner.

Query parameters

Parameter Type Description
page integer Page number (default: 1)
limit integer Results per page, max 100 (default: 20)
q string Full-text search on title and description
tag string Filter by a single tag (exact match)
tags string Comma-separated list — albums containing any of these tags
visibility all | public | private Default: all
sortBy createdAt | updatedAt | eventDate | title Default: createdAt
order asc | desc Default: desc
fromEventDate Unix timestamp Filter albums with eventDate ≥ value
toEventDate Unix timestamp Filter albums with eventDate ≤ value

Response

{
  "success": true,
  "data": {
    "albums": [
      {
        "id": "uuid",
        "title": "Spring Shoot 2025",
        "description": "...",
        "tags": ["portrait", "outdoor"],
        "eventDate": 1743465600,
        "isPublic": true,
        "photoCount": 42,
        "createdAt": 1743465600,
        "updatedAt": 1743465600,
        "coverPhoto": { "id": "uuid", "blurhash": "..." },
        "coverThumbUrl": "/api/assets/thumb/<id>",
        "coverFullUrl": "/api/assets/full/<id>"
      }
    ],
    "pagination": {
      "page": 1,
      "limit": 20,
      "total": 5,
      "hasMore": false
    },
    "timeline": [
      { "year": 2025, "month": 4, "count": 3 }
    ]
  }
}

GET /api/external/albums/:id

Scope: albums:read

Get full detail for a single album.

Response

{
  "success": true,
  "data": {
    "id": "uuid",
    "title": "Spring Shoot 2025",
    "description": "...",
    "tags": ["portrait"],
    "eventDate": 1743465600,
    "isPublic": true,
    "photoCount": 42,
    "collaboratorCount": 3,
    "createdAt": 1743465600,
    "updatedAt": 1743465600,
    "owner": { "id": "uuid", "name": "Alice", "instagram": "alice.photo" },
    "coverPhoto": { "id": "uuid", "blurhash": "..." },
    "coverThumbUrl": "/api/assets/thumb/<id>",
    "coverFullUrl": "/api/assets/full/<id>",
    "shareLinks": [
      { "id": "uuid", "token": "...", "type": "view", "views": 12 }
    ]
  }
}

GET /api/external/albums/:id/photos

Scope: photos:read

List photos in an album with pagination.

Query parameters

Parameter Type Description
page integer Page number (default: 1)
limit integer Max 100 (default: 20)
orientation any | landscape | portrait | square Default: any
sortBy createdAt | dateTaken | originalName Default: createdAt
order asc | desc Default: desc
fromDateTaken Unix timestamp Filter photos taken on or after this date
toDateTaken Unix timestamp Filter photos taken on or before this date

Response

{
  "success": true,
  "data": {
    "photos": [
      {
        "id": "uuid",
        "filename": "...",
        "originalName": "DSC_0042.jpg",
        "width": 5472,
        "height": 3648,
        "blurhash": "LGF5?xYk^6#M@-5c,1J5@[or[Q6.",
        "mimeType": "image/jpeg",
        "size": 8192000,
        "dateTaken": 1743465600,
        "cameraModel": "Nikon D850",
        "lens": "NIKKOR 24-70mm f/2.8",
        "focalLength": "35.0mm",
        "iso": 400,
        "aperture": "f/2.8",
        "shutterSpeed": "1/500s",
        "thumbUrl": "/api/assets/thumb/<id>",
        "fullUrl": "/api/assets/full/<id>",
        "uploader": { "id": "uuid", "name": "Alice", "instagram": "alice.photo" }
      }
    ],
    "pagination": {
      "page": 1,
      "limit": 20,
      "total": 42,
      "hasMore": true
    }
  }
}

GET /api/external/albums/:id/random

Scope: photos:read

Get random photos from a specific album.

Query parameters

Parameter Type Description
count integer Number of photos to return (default: 1, max: 50)
orientation any | landscape | portrait | square Default: any
fromDateTaken Unix timestamp Only include photos taken after this date
toDateTaken Unix timestamp Only include photos taken before this date

GET /api/external/photos/random

Scope: photos:read

Get random photos from across all albums (or a filtered subset).

Query parameters

Parameter Type Description
count integer Number of photos (default: 1, max: 50)
albumId UUID Restrict to a single album
tag string Restrict to albums with this tag
orientation any | landscape | portrait | square Default: any
visibility all | public | private Default: all
fromDateTaken Unix timestamp
toDateTaken Unix timestamp

Response — same shape as the photo object in the albums/photos endpoint.


Serving assets

Asset URLs returned by the API require an Authorization header or an access_token query parameter when the album is private.

GET /api/assets/thumb/<photo_id>     # WebP thumbnail (~400px)
GET /api/assets/full/<photo_id>      # Original file

For use in <img> tags, append ?access_token=<token> since browsers cannot set custom headers:

/api/assets/full/<id>?access_token=<api_token>

Authentication

PicHaus uses a custom HMAC-SHA256 token scheme rather than a JWT library.

Format: base64url(payload) . HMAC-SHA256(payload, AUTH_SECRET)

Session tokens

  • Created on login (password, passkey, Google, Microsoft, or guest upload flow), valid for 7 days
  • Stored in localStorage under the key pichaus_access_token
  • Sent as Authorization: Bearer <token> on every API call
  • For image asset URLs, appended as ?access_token=<token>

Passkeys and security keys

PicHaus supports WebAuthn/FIDO2 passkeys and hardware security keys (YubiKey, etc.) as a passwordless login method via @simplewebauthn.

  • Sign in — the login page has a Sign in with Passkey button. The browser or OS prompts the user to select a registered credential. No email or password is entered.
  • Register a passkey — go to SettingsPasskeys & Security Keys → click Add. The browser prompts to create a new credential using the platform authenticator (Face ID, Touch ID, Windows Hello) or a plugged-in hardware key. Multiple passkeys can be registered per account.
  • Manage passkeys — each registered passkey is listed by name and transport (Built-in, USB, NFC, Bluetooth). Individual passkeys can be removed at any time.
  • Passkey challenges expire after 5 minutes and are consumed on first use (replay-safe).
  • Credentials are stored in the passkeys table as credentialId, publicKey (base64url), and a replay counter.

API tokens

  • Prefixed pk_ followed by 64 hex characters
  • Stored as SHA-256 hashes in the database (never the raw token)
  • Scoped: photos:read and/or albums:read
  • Optional expiry; last-used timestamp updated asynchronously

Password hashing

All passwords (user accounts and share link passwords) are hashed with Argon2id:

  • Memory cost: 19 MiB
  • Time cost: 2 iterations
  • Parallelism: 1

Storage

Files are stored through the configured storage backend. By default PicHaus uses the local filesystem at STORAGE_DIR (default storage/uploads). Set STORAGE_DRIVER=s3 to store photos, thumbnails, logos, and avatars in an S3-compatible bucket.

Asset delivery defaults to ASSET_DELIVERY=proxy, where browsers request PicHaus asset endpoints and PicHaus streams bytes from storage after enforcing auth/share-link checks. With ASSET_DELIVERY=redirect, PicHaus still performs the same access checks, then returns a 302 to the bucket or CDN so the browser downloads the file directly.

Storage backend choices

Backend Configuration Best for
Local filesystem STORAGE_DRIVER=local and STORAGE_DIR=/path/to/uploads Small/self-hosted installs with a durable disk or Docker volume
S3-compatible bucket STORAGE_DRIVER=s3 plus S3_* credentials Larger libraries, object storage backups/lifecycle policies, multi-host deployments

Asset delivery choices

Delivery mode Configuration Browser receives Bucket visibility Tradeoff
Proxy ASSET_DELIVERY=proxy PicHaus /api/assets/... response body Private Strongest control and simplest setup, but PicHaus pays bandwidth and handles streaming
Redirect with presigned URLs ASSET_DELIVERY=redirect, no S3_PUBLIC_BASE_URL Short-lived signed bucket URL Private Saves PicHaus bandwidth while preserving private objects; URLs remain valid until TTL expiry
Redirect to CDN/public base ASSET_DELIVERY=redirect and S3_PUBLIC_BASE_URL=... Public bucket/CDN URL Public or CDN-authorized Lowest PicHaus bandwidth and best CDN caching, but object access is controlled outside PicHaus after redirect

Local directory layout

storage/uploads/
├── avatars/         # User avatars
├── logos/           # Site, OAuth button, album, and share group logos
├── photos/          # Original uploaded files + cover photos
├── resumable/       # Temporary resumable-upload sessions
└── thumbnails/      # WebP thumbnails (max 400×400)

File naming

<first_16_chars_of_sha256>_<unix_ms>.<ext>

Example: a3f9c12d8e4b7f01_1743465600000.jpg

On upload

  1. MIME type verified by Sharp (not just the file extension)
  2. SHA-256 hash computed for duplicate detection
  3. EXIF data extracted
  4. WebP thumbnail generated at ≤400×400
  5. Blurhash generated at 32×32 for progressive loading
  6. Both files written to the configured storage backend, then the database record is created
  7. If the database write fails, both files are deleted (no orphans)

Cover photos are processed to JPEG at up to 2560×2560 and stored alongside regular photos.

S3-compatible storage

Set the bucket credentials and switch the driver. PicHaus signs S3 requests itself using SigV4, so no AWS SDK dependency is required.

STORAGE_DRIVER="s3"
S3_BUCKET="pichaus"
S3_REGION="us-east-1"
S3_ENDPOINT="https://s3.example.com"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."
S3_PREFIX="production"

Stored object keys keep the same internal layout (photos/..., thumbnails/..., logos/..., avatars/...), optionally under S3_PREFIX. Existing local files are not migrated automatically. Resumable-upload chunks are still staged on the PicHaus server under STORAGE_DIR/resumable until each upload completes, then the final file is written to S3.

The access key must be able to:

  • PutObject for uploads, thumbnails, logos, avatars, and health checks
  • GetObject for proxy reads, image processing, rotations, cover crops, and presigned redirects
  • HeadObject for asset existence and cache metadata
  • DeleteObject for deleted photos/logos and health checks

For direct browser reads, keep the bucket private and omit S3_PUBLIC_BASE_URL to use short-lived presigned S3 URLs:

ASSET_DELIVERY="redirect"
S3_PRESIGNED_URL_TTL_SECONDS="300"

The browser first requests /api/assets/...; PicHaus validates album ownership, collaborator access, API-token/share-link cookies, and public-album rules. Only after that check does PicHaus return a 302 to a presigned URL. The default TTL is 300 seconds and is clamped between 1 second and 7 days.

If objects are intentionally public behind a bucket website or CDN, set S3_PUBLIC_BASE_URL instead:

ASSET_DELIVERY="redirect"
S3_PUBLIC_BASE_URL="https://cdn.example.com/pichaus/"

When S3_PUBLIC_BASE_URL is set, PicHaus maps internal object keys directly under that base URL. For example, with S3_PREFIX=production and S3_PUBLIC_BASE_URL=https://cdn.example.com/pichaus/, photos/a.jpg redirects to https://cdn.example.com/pichaus/production/photos/a.jpg.

AWS S3 example

STORAGE_DRIVER="s3"
ASSET_DELIVERY="proxy"
S3_BUCKET="pichaus-prod"
S3_REGION="us-east-1"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."
S3_PREFIX="uploads"

For AWS S3, omit S3_ENDPOINT. PicHaus defaults to virtual-hosted-style URLs for AWS S3. Set ASSET_DELIVERY=redirect to use presigned browser downloads.

Cloudflare R2 example

STORAGE_DRIVER="s3"
ASSET_DELIVERY="redirect"
S3_BUCKET="pichaus"
S3_REGION="auto"
S3_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."
S3_FORCE_PATH_STYLE="true"
S3_PRESIGNED_URL_TTL_SECONDS="300"

R2 supports SigV4-style presigned URLs. Custom endpoints default to path-style URLs, but setting S3_FORCE_PATH_STYLE=true makes that explicit.

MinIO example

STORAGE_DRIVER="s3"
ASSET_DELIVERY="proxy"
S3_BUCKET="pichaus"
S3_REGION="us-east-1"
S3_ENDPOINT="https://minio.example.com"
S3_ACCESS_KEY_ID="..."
S3_SECRET_ACCESS_KEY="..."
S3_FORCE_PATH_STYLE="true"

If you put MinIO behind a public reverse proxy or CDN and want direct reads, set ASSET_DELIVERY=redirect and either use presigned URLs or set S3_PUBLIC_BASE_URL to the public object URL prefix.

Operational notes

  • Keep ASSET_DELIVERY=proxy if you need PicHaus to remain the only host clients contact for media.
  • Use ASSET_DELIVERY=redirect to reduce PicHaus egress and CPU load for image downloads.
  • Presigned redirects expose temporary object URLs to the user who passed PicHaus access checks. Use a short TTL if album membership or share-link access changes often.
  • Public/CDN redirects do not make PicHaus re-check access after the redirect; configure bucket/CDN policies accordingly.
  • Switching from local storage to S3 changes where new files are written. Copy existing STORAGE_DIR contents to matching S3 keys before switching a production instance.

Database

PicHaus uses PostgreSQL via Drizzle ORM. All timestamps are stored as Unix seconds (BigInt).

Auto-migration on startup

Migrations run automatically every time the server starts — no manual steps required when upgrading. The runner (a Nitro server plugin) applies any pending SQL files from drizzle/migrations/ before the first request is served. Migration SQL is bundled into the production build so the .output directory is fully self-contained.

On first boot after upgrading from a Prisma-managed database, the runner detects the existing schema and stamps the migrations as already applied without re-running them — your data is untouched.

Key tables

Table Description
users Accounts — email, Argon2id password hash, name, Instagram, role
logos Reusable logo assets for site branding, OAuth buttons, albums, and share groups
albums Photo collection — title, description, tags, event date, visibility, cover photo
photos Image file — storage paths, dimensions, blurhash, SHA-256 hash, full EXIF data
share_links Token-based share link — type (view/upload), optional password, expiry, metadata flag, and upload message
share_groups Bundles multiple albums under one share link, with optional theme and branding
album_collaborators Per-album role assignment (viewer / editor / admin)
api_tokens External API token — hashed, scoped, optional expiry
passkeys WebAuthn/FIDO2 credentials for passwordless login
invite_tokens Invite and password-reset tokens
site_settings Global site branding, registration, and OAuth button configuration

Schema changes

Migration files live in drizzle/migrations/. To add a column or table:

  1. Edit server/db/schema.ts
  2. bun run db:generate — generates a new SQL migration file (requires DATABASE_URL)
  3. Commit both the schema change and the generated SQL file
  4. Deploy — the runner applies the new migration automatically on next boot
bun run db:generate   # generate migration SQL from schema changes
bun run db:migrate    # apply migrations manually (normally not needed)
bun run db:studio     # open Drizzle Studio to browse the database

About

A self-hosted, collaborative photo album platform, for photographers, by a photographer

Topics

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages