A lightweight, self-hosted static file server with a built-in admin interface protected by OIDC authentication.
Serve files and static websites publicly, manage them securely — works with any OIDC-compliant identity provider (Amazon Cognito, Authentik, Authelia, Keycloak, and more).
- Raw file serving — files are served exactly as-is, with no processing or wrapping. Enables full static website hosting (
index.htmlauto-served per directory). - Public file browser — clean dark UI with directory listings and inline previews for images, text/code, video, and audio.
- Protected admin panel — upload, delete, rename, and create folders via
/_admin, secured by OIDC. - Backend-for-Frontend (BFF) auth — tokens never touch the browser. Auth is handled server-side and stored in HttpOnly cookies, protecting against XSS.
- Any OIDC provider — works with any standards-compliant IdP. Group-based access control via a configurable JWT claim.
- Single container — nginx + Express + React in one Docker image. No sidecars, no databases, no external auth middleware needed.
docker pull ghcr.io/iamabrom/static-oidc:latestservices:
static-oidc:
image: ghcr.io/iamabrom/static-oidc:latest
container_name: static-oidc
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ${FILES_PATH}:/srv/files
environment:
- TZ=${TZ}
- APP_URL=${APP_URL}
- APP_NAME=${APP_NAME:-static-oidc}
- OIDC_ISSUER_URL=${OIDC_ISSUER_URL}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_GROUPS_CLAIM=${OIDC_GROUPS_CLAIM:-groups}
- OIDC_ADMIN_GROUP=${OIDC_ADMIN_GROUP}TZ=America/New_York
FILES_PATH=/path/to/your/files
APP_URL=https://files.example.com
APP_NAME=My Files
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_ADMIN_GROUP=static-oidc-admins
OIDC_GROUPS_CLAIM=groupsdocker compose up -d| Variable | Required | Description |
|---|---|---|
TZ |
Yes | Timezone e.g. America/New_York |
FILES_PATH |
Yes | Host path to serve files from e.g. /mnt/files |
APP_URL |
Yes | Full public URL, no trailing slash |
OIDC_ISSUER_URL |
Yes | Your IdP's issuer URL |
OIDC_CLIENT_ID |
Yes | OIDC app client ID |
OIDC_CLIENT_SECRET |
Yes | OIDC app client secret |
OIDC_ADMIN_GROUP |
Yes | IdP group name required for admin access |
OIDC_GROUPS_CLAIM |
No | JWT claim for groups. Defaults to groups |
APP_NAME |
No | Display name in the UI header and browser tab. Defaults to static-oidc |
Your IdP app client must be configured as a confidential client (with a client secret) with the following:
- Allowed callback URL:
{APP_URL}/callback - Allowed sign-out URL:
{APP_URL} - Grant type: Authorization code
- Scopes:
openid,email,profile - A group matching
OIDC_ADMIN_GROUPwith your admin users assigned to it - Groups included in the access token under the claim matching
OIDC_GROUPS_CLAIM
AWS Cognito: Set OIDC_GROUPS_CLAIM=cognito:groups. Ensure the hosted UI is enabled with a domain configured.
Authentik / Authelia / Keycloak: Use OIDC_GROUPS_CLAIM=groups (the default). Ensure groups are mapped into the access token scope.
There are two ways to run locally — dev servers for fast iteration, or Docker for a full end-to-end test.
You'll need Node.js 24+ and pnpm.
1. Create a test files folder:
mkdir -p ~/static-oidc-filesDrop a few files and a subfolder in there so you have something to browse.
2. Create backend/.env:
APP_URL=http://localhost:5173
APP_NAME=My Files
FILES_PATH=/Users/YOUR_USERNAME/static-oidc-files
OIDC_ISSUER_URL=https://your-idp.example.com
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_ADMIN_GROUP=static-oidc-admins
OIDC_GROUPS_CLAIM=groupsThe frontend has no build-time environment variables — it fetches config from the backend at runtime via
/api/config. Nofrontend/.envfile is needed.
3. Start both processes:
Terminal 1 — backend:
cd backend
pnpm install
pnpm devTerminal 2 — frontend:
cd frontend
pnpm install
pnpm devOpen http://localhost:5173. The Vite dev server proxies /api/* to Express on port 3001.
Add
http://localhost:5173/callbackas an allowed redirect URI in your IdP before testing auth.
What works: directory listing, full auth flow, admin panel, upload, delete, rename, create folder.
What doesn't work: direct file downloads and previews. Clicking a non-directory file redirects to its natural path (e.g. /photo.jpg), which nginx serves from the files volume — nginx isn't running in this mode. Use Option B to test file serving end-to-end.
1. Create a test files folder:
mkdir -p ~/static-oidc-files2. Create a .env file in the project root:
APP_URL=http://localhost:8080
APP_NAME=My Files
FILES_PATH=/Users/YOUR_USERNAME/static-oidc-files
OIDC_ISSUER_URL=https://your-idp.example.com
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_ADMIN_GROUP=static-oidc-admins
OIDC_GROUPS_CLAIM=groups
TZ=America/New_YorkNote APP_URL uses port 8080 to match the docker-compose.yml port mapping.
3. Build and start:
docker compose up --buildOpen http://localhost:8080. Everything works — nginx serves the React app, proxies API requests to Express, and serves raw files from /files/*.
Add
http://localhost:8080/callbackas an allowed redirect URI in your IdP before testing auth.
Mac + Docker Desktop: Docker Desktop allows mounting paths under
/Usersby default, so~/static-oidc-fileswill work. If you see volume mount errors, check Docker Desktop → Settings → Resources → File Sharing.
No build arguments are needed. The Docker image is fully portable — APP_URL and OIDC_CLIENT_ID are injected at runtime, not baked into the build.
After running
pnpm installin bothfrontend/andbackend/, commit the generatedpnpm-lock.yamlfiles. The Docker build will use them for reproducible installs.
A pre-built image is published to GitHub Container Registry on every push to main:
ghcr.io/iamabrom/static-oidc:latest
In Dokploy (or any Docker host), use this docker-compose.yml:
services:
static-oidc:
image: ghcr.io/iamabrom/static-oidc:latest
pull_policy: always
container_name: static-oidc
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ${FILES_PATH}:/srv/files
environment:
- TZ=${TZ}
- APP_URL=${APP_URL}
- APP_NAME=${APP_NAME:-static-oidc}
- OIDC_ISSUER_URL=${OIDC_ISSUER_URL}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
- OIDC_GROUPS_CLAIM=${OIDC_GROUPS_CLAIM:-groups}
- OIDC_ADMIN_GROUP=${OIDC_ADMIN_GROUP}pull_policy: always ensures Docker always pulls the latest image from GHCR on redeploy rather than using a locally cached copy.
Set your environment variables in Dokploy's UI. Domain and SSL termination are handled by Dokploy's reverse proxy — no changes to the compose file are needed.
Browser
│
├── /files/* → nginx serves raw files directly from the mounted volume
├── /api/* → nginx proxies to Express backend (port 3001)
└── everything else → nginx checks for index.html in mounted volume first
│
├── index.html exists at path → served directly (static site mode)
└── no index.html → React SPA
│
├── /* Public file browser
├── /callback OIDC callback handler
└── /_admin/* Admin panel (requires OIDC auth)
Auth flow:
- User visits
/_admin→ frontend calls/api/auth/login→ redirected to IdP - IdP redirects back to
/callbackwith an authorization code - Backend exchanges the code for tokens using the client secret
- Tokens stored in an HttpOnly cookie — JavaScript never sees them
- Every admin API call verifies the JWT using the IdP's JWKS (cached in memory)
- Expired access tokens are silently refreshed using the refresh token
- Frontend: React 18, React Router v6, TypeScript, Vite
- Backend: Express 5, TypeScript,
jose(JWT/JWKS) - Container: nginx, supervisord, Node 24 Alpine, multi-stage Docker build
MIT