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.
- Features
- Tech Stack
- Quick Start
- Docker Deployment
- Environment Variables
- First-Time Setup
- User Guide
- OAuth and Registration
- Invites and Password Resets
- Branding
- External API Reference
- Authentication
- Storage
- Database
- 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
vieworuploadlinks 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
| 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 |
- 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 devThe 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 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 \
pichausNote:
DATABASE_URLis only needed at runtime — the build step has no database dependency.
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:| 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_SECRETmust 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_IDto your bare domain (e.g.photos.example.com),WEBAUTHN_ORIGINtohttps://photos.example.com, andWEBAUTHN_RP_NAMEto 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/callbackas an authorised redirect URI. Set bothGOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETto enable the feature — Google Sign-In is hidden from the UI whenGOOGLE_CLIENT_IDis absent.
Microsoft Sign-In: Create an app registration in Microsoft Entra ID, add
<origin>/api/v1/auth/microsoft/callbackas a web redirect URI, and set bothMICROSOFT_CLIENT_IDandMICROSOFT_CLIENT_SECRET. The app requestsopenid email profile User.Read. Admins can enable the button and optionally set a tenant ID from Admin → Settings;commonis 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_QUALITYdefault: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.5MB/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.
- Navigate to
http://your-host:3000— you are automatically redirected to/setup - Enter a name, email address, and password (minimum 8 characters) for the admin account
- Click Complete Setup — you are redirected to
/login - Sign in with the credentials you just created
The setup endpoint is permanently disabled once the first account exists.
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
- Go to Albums → click Create Album
- Fill in the title and optional fields
- 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
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).
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.
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.
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
localStoragekeyed 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)
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
- Open the share link URL (
/v/<token>) and click Upload Photos - If password-protected, enter the password
- Enter a display name and optionally an email and Instagram handle
- Drag and drop photos onto the upload zone, or click to browse — a full-page overlay activates when files are dragged over the window
- A per-file thumbnail queue appears showing each file's status: pending → hashing → uploading → done / duplicate / error
- An overall progress bar tracks the batch; a summary (N uploaded · N duplicates skipped · N failed) appears on completion
- 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.
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 allow external services (personal websites, scripts, integrations) to query your albums and photos via the External API.
Creating a token
- Go to API Keys in the sidebar
- Enter a token name (e.g. "My Portfolio Site")
- Select the scopes you need (
photos:read,albums:read) - 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.
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.
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.
Email/password login is always available for existing accounts. Public self-registration is controlled by Admin → Settings → Allow public registration.
Google and Microsoft OAuth have two layers:
- Environment variables provide the provider credentials.
- 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.
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,
usedAtis recorded and the token cannot be reused.
Invite links are opened at /invite/<token>.
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>.
All external endpoints require:
Authorization: Bearer <api_token>
Responses are JSON with a top-level success: true field. Timestamps are Unix seconds (integers).
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 }
]
}
}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 }
]
}
}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
}
}
}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 |
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.
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>
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
localStorageunder the keypichaus_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 Settings → Passkeys & 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
passkeystable ascredentialId,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:readand/oralbums: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
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
- MIME type verified by Sharp (not just the file extension)
- SHA-256 hash computed for duplicate detection
- EXIF data extracted
- WebP thumbnail generated at ≤400×400
- Blurhash generated at 32×32 for progressive loading
- Both files written to the configured storage backend, then the database record is created
- 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:
PutObjectfor uploads, thumbnails, logos, avatars, and health checksGetObjectfor proxy reads, image processing, rotations, cover crops, and presigned redirectsHeadObjectfor asset existence and cache metadataDeleteObjectfor 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=proxyif you need PicHaus to remain the only host clients contact for media. - Use
ASSET_DELIVERY=redirectto 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_DIRcontents to matching S3 keys before switching a production instance.
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:
- Edit
server/db/schema.ts bun run db:generate— generates a new SQL migration file (requiresDATABASE_URL)- Commit both the schema change and the generated SQL file
- 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