Layera pairs a fast, polished UI (Kumo, Cloudflare's
design system) with S3-compatible object storage (MinIO). Postgres is the
source of truth for the file tree, permissions, and metadata; MinIO only
ever stores bytes. See CLAUDE.md for the full product plan
and docs/superpowers/specs/ for sub-project
specs.
- Auth — email/password, email OTP, TOTP two-factor with backup codes, GitHub/Google social login, multi-session account switching
- Workspaces — a personal space per user plus team/organization workspaces, each fully isolated (files, tags, shares, storage stats)
- Files & folders — create, rename, move (drag-and-drop), trash with restore, multi-select bulk delete, folder color customization
- Upload — multi-file upload with progress (presigned MinIO PUT, UUID object keys)
- Worker — image thumbnails, trash purge, expired upload cleanup
- Views — sortable/filterable table and grid views, URL-driven pagination, favorites, recents
- Tags — workspace-wide colored tags, assignable to any file or folder
- Previews — images, PDF, video/audio (with HTTP range seeking), Markdown (sanitized), and syntax-highlighted code
- Sharing — public links with optional password and expiration
- Admin — instance settings (registration, sharing, teams, favorites, tags, archive, quotas), staff roles (admin / moderator / support), user/workspace panel, in-app update banner from GitHub Releases
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router), React 19, TypeScript |
| UI | Kumo + Tailwind CSS |
| Auth | Better Auth (email/OTP/2FA, social, orgs) |
| Database | PostgreSQL + Drizzle ORM |
| Storage | MinIO (S3-compatible) |
| Monorepo | pnpm workspaces |
| CI/CD | GitHub Actions |
layera/
├── apps/
│ ├── web/ # Next.js app
│ └── worker/ # Thumbnails, trash purge, expired upload cleanup
├── packages/
│ ├── db/ # Drizzle schema, client, migrations
│ ├── storage/ # MinIO client + helpers
│ ├── config/ # Shared ESLint/TypeScript config
│ └── types/ # Shared domain types
├── docker-compose.yml # Local dev stack
├── docker-compose.prod.yml # Production stack
└── .github/workflows/ # CI, Docker publish, dependency audit
cp .env.example .envand fill inBETTER_AUTH_SECRET,S3_ACCESS_KEY,S3_SECRET_KEY. Put your email inADMIN_EMAILSso the first login is promoted to admin.cp .env apps/web/.env, then editapps/web/.envsoDATABASE_URLuseslocalhostinstead ofpostgresas the host (thepostgreshostname only resolves inside the Docker network). Also setS3_ENDPOINT=localhostandS3_PORT=9010there — Compose publishes MinIO on9010.pnpm installdocker compose up -d postgres minio- Create the
filecloudbucket in the MinIO console athttp://localhost:9011. export $(grep -v '^#' apps/web/.env | xargs)pnpm db:migratepnpm dev— app runs athttp://localhost:3000.pnpm dev:worker— thumbnails and deferred deletion (optional in localpnpm dev; included in Docker Compose).
Or run everything in Docker: docker compose up --build. The web service runs on
http://localhost:3000, the worker processes jobs, Postgres on localhost:5432, the MinIO S3 API on
http://localhost:9010, and the MinIO console on http://localhost:9011.
Copy .env.example for local Compose / pnpm dev, and
.env.production.example for production.
NODE_ENV, PORT, HOSTNAME, and APP_VERSION are set by Next.js or
the Docker image — you do not need them in .env.
| Variable | Notes |
|---|---|
DATABASE_URL |
Postgres connection string. Host postgres inside Compose; localhost for pnpm dev. |
BETTER_AUTH_SECRET |
openssl rand -base64 32. Also signs public-share unlock cookies. |
BETTER_AUTH_URL |
Public origin Better Auth issues cookies for. |
NEXT_PUBLIC_BETTER_AUTH_URL |
Optional, and only honoured at build time — NEXT_PUBLIC_* is inlined into the browser bundle. Leave it unset so the browser calls the origin it loaded the page from. |
| Variable | Notes |
|---|---|
S3_ENDPOINT |
MinIO hostname (minio in Docker, localhost for pnpm dev). |
S3_PORT |
MinIO API port (9000 in Docker, 9010 on the host). |
S3_ACCESS_KEY / S3_SECRET_KEY |
MinIO root credentials. |
S3_BUCKET |
Bucket name (create it once in the MinIO console). |
S3_USE_SSL |
true if MinIO is reached over HTTPS. Default false. |
S3_PUBLIC_ENDPOINT |
Browser-facing MinIO URL for presigned PUT/GET (e.g. http://localhost:9010). Leave unset in production if MinIO is not reverse-proxied — uploads then go through /api/uploads/[id]. |
These seed the instance defaults. Admins can change them later in Admin → Settings.
| Variable | Default | Notes |
|---|---|---|
MAX_UPLOAD_BYTES |
5 GiB | Per-file upload cap. |
MAX_WORKSPACE_BYTES |
10 GiB | Default workspace quota. |
| Variable | Default | Notes |
|---|---|---|
DATABASE_POOL_MAX |
10 |
pg pool size. |
DATABASE_SSL |
false |
Set true for managed Postgres that requires TLS. |
DATABASE_SSL_REJECT_UNAUTHORIZED |
true |
Only used when DATABASE_SSL=true. Set false for a private CA. |
| Variable | Required | Notes |
|---|---|---|
ADMIN_EMAILS |
bootstrap | Comma-separated emails promoted to admin on every login. This is the only way to create the first admin. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
– | Enables "Continue with GitHub". |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
– | Enables "Continue with Google". |
| Variable | Default | Notes |
|---|---|---|
TRUST_PROXY |
false (true in production) |
Trust X-Forwarded-For when the app sits behind nginx/Caddy. |
CRON_SECRET |
unset | Protects POST /api/cron/purge-trash (Authorization: Bearer …). The Compose worker already purges trash; set this only if you call the HTTP route instead. |
| Variable | Notes |
|---|---|
GITHUB_REPO |
Override AloneDay-91/filecloud-v2 for the admin update banner. |
GITHUB_TOKEN |
Optional GitHub token to raise Releases API rate limits. |
APP_VERSION |
Stamped into the web image from the git tag (v1.1.0 → 1.1.0). Dokploy source builds read the repo VERSION file when this is unset. Local fallback is 0.0.0-dev. |
Set these in .env.production. DATABASE_URL is built from the Postgres
trio — do not set it yourself.
| Variable | Notes |
|---|---|
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
Credentials for the Postgres container. |
LAYERA_VERSION |
GHCR web image tag (v1.1.0). Defaults to latest. |
| Variable | Notes |
|---|---|
ANTHROPIC_API_KEY |
Used only by pnpm --filter @filecloud/web i18n:translate. |
pnpm test runs:
- workspace isolation (
packages/db) — a stranger cannot join another personal workspace, and object keys never contain the display name - storage key helpers (
packages/storage) — UUID paths, no..traversal - MIME sniffing, zip path safety, and i18n key shape (
apps/web)
The database tests need Postgres running and DATABASE_URL pointing at localhost:
docker compose up -d postgres
export DATABASE_URL=postgresql://filecloud:filecloud@localhost:5432/filecloud
pnpm testEvery push/PR also runs pnpm lint, pnpm typecheck, pnpm test, and pnpm build in CI.
apps/web/Dockerfile builds a standalone Next.js production image
published to ghcr.io/aloneday-91/filecloud-v2. docker-compose.prod.yml
pulls that image for web and builds the worker locally, with a
deliberately locked-down network:
- Postgres and MinIO publish no ports to the host at all — only reachable from other containers on the compose network.
webbinds to127.0.0.1:3001— a different port than the dev stack's3000, and loopback-only. Point an HTTPS reverse proxy (nginx/Caddy) at it; the app itself is never internet-facing.- Presigned PUT/GET are used when
S3_PUBLIC_ENDPOINTis set and MinIO is reverse-proxied. Otherwise the app falls back to/api/uploads/[id]and/api/files/contentso the bucket can stay private.
cp .env.production.example .env.production # fill in secrets and ADMIN_EMAILS
docker login ghcr.io # if the package is private
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --build--build compiles the worker on the host. web comes from GHCR (latest,
or pin LAYERA_VERSION=v1.1.0 in the compose env).
A green CI run on main cuts the next semver tag, creates a GitHub Release
whose notes list every change since the previous tag, and publishes
ghcr.io/aloneday-91/filecloud-v2:vX.Y.Z (and latest).
The bump comes from commit messages since the last tag:
| Commits since last tag | Bump |
|---|---|
feat!: or BREAKING CHANGE |
major (1.2.0 → 2.0.0) |
feat: |
minor (1.2.0 → 1.3.0) |
anything else (fix:, docs:, …) |
patch (1.2.0 → 1.2.1) |
You can still publish an exact version by hand:
git tag v1.2.0
git push origin v1.2.0Admins see a banner when a newer release exists. Update the running app:
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -dOptional GITHUB_TOKEN in .env.production raises the GitHub API rate limit
used by the banner.
Postgres and MinIO use named Docker volumes (postgres_data, minio_data).
Keep them on separate schedules.
Dump Postgres
docker compose exec -T postgres pg_dump -U filecloud filecloud > backup-$(date +%F).sqlCopy the MinIO volume (objects only; the SQL dump is the source of truth for names, tree, and permissions):
docker run --rm -v filecloud-v2_minio_data:/data -v "$PWD":/backup alpine \
tar czf /backup/minio-$(date +%F).tar.gz -C /data .Restore
- Stop the stack:
docker compose down - Restore Postgres:
docker compose up -d postgresthendocker compose exec -T postgres psql -U filecloud filecloud < backup-YYYY-MM-DD.sql - Restore MinIO: extract the tarball back into the
minio_datavolume docker compose up -d
Object keys are workspaces/{workspaceId}/{uuid} — never the original file
name — so a restored dump cannot be used to guess another tenant's objects.
Three GitHub Actions workflows live under .github/workflows/:
ci.yml— lint, typecheck, and build on every push/PR tomain, plus apnpm audit --audit-level=highgate. Registry timeouts are retried, then skipped, so a dead npm audit API does not block the rest of CI; real high/critical findings still fail the job.docker-publish.yml— once CI is green onmain(or on a manualvX.Y.Ztag): bumps semver, pushes the tag, creates the GitHub Release, stampsAPP_VERSION, and publishesghcr.io/aloneday-91/filecloud-v2.dependency-audit-fix.yml— weeklypnpm audit --fix, re-verified against lint/typecheck/build, opened as a PR for review (never pushed directly tomain).