A self-hosted file library with a public browser and a private owner dashboard. Built with React, TypeScript, Bun, and SQLite. One app, one persistent volume, no external services.
Create the configuration, set a strong owner password (at least 12 characters), and start the app:
cp .env.example .env
# Edit .env and set ADMIN_PASSWORD.
docker compose up -d --buildOpen http://localhost:3000. Use Owner sign in with the username and password in .env. Anyone can browse public files; only the owner can upload, create folders, move, rename, delete, or change protection.
If you have Bun installed, bun run setup creates .env with a random password instead. It never overwrites an existing configuration.
Configure the sidebar name and browser tab title in .env:
SITE_NAME="My CDN"
SITE_TITLE="My file library"Both default to generic File CDN branding. If SITE_TITLE is empty, it uses <SITE_NAME> — Files. Settings apply at runtime: restart bun dev / bun start, or run docker compose up -d to recreate the container with the updated environment. After initially upgrading to this version, rebuild the image with docker compose up -d --build.
- Responsive public library, grid/list views, file previews, current-folder search, sorting, and category filters.
- Owner login with hashed passwords and expiring, server-side sessions.
- Multiple-file uploads with drag and drop and progress; configurable batch size limit (100 MB by default).
- Nested folders, drag-and-drop moves, rename, copy sharing links or direct file URLs, download, and permanent deletion.
- Passwords on individual files or entire folders. Every protected ancestor must be unlocked, including for direct downloads. Unlocks last up to seven days in the visitor’s browser; replacing a password revokes previous unlocks.
- Safe image, audio, and video previews, including byte-range requests. Active content such as HTML and SVG downloads as an attachment.
- Readable download URLs such as
/logo.pngand/design-stuff/logo.png. Existing ID-based links remain supported and stable through renames. - SQLite and uploaded files stored together in the persistent
cdn-datavolume. No demo files are inserted.
Navigation filters and statistics apply to the current folder, except Stored total, which sums the sizes of all files in the CDN, including files inside protected folders. A folder’s name is visible in its parent listing, but its contents require its password. File names, sizes, and creation dates are visible in their parent listing; protection restricts access to content. Files inside protected folders inherit their access requirements. Changing the owner password in .env and restarting revokes existing owner sessions.
Put the app behind your existing HTTPS reverse proxy and set:
PUBLIC_ORIGIN=https://cdn.jsemolik.dev
COOKIE_SECURE=trueForward requests to port 3000. For example, inside your existing TLS-enabled nginx server block:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
client_max_body_size 101m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}Set nginx’s body limit slightly above MAX_UPLOAD_MB to allow multipart headers. If nginx runs in another container, put it on the same Docker network and use http://cdn:3000. For a proxy on the same machine, you can bind the Compose port to loopback (127.0.0.1:${PORT:-3000}:3000). TLS certificates stay with your proxy.
Restart after configuration changes:
docker compose up -dThe initial migration of existing nginx files is manual: upload them through the owner interface and recreate folders as needed. Recreating the same folder structure and filenames preserves matching path-based URLs. This application is an origin file server; geographically distributed caching requires a separate CDN. Protected downloads use private, no-store so a shared cache cannot serve them to another visitor.
“Copy file URL” uses the file’s folder path, for example /209-logo-120.png or /design-stuff/209-logo-120.png. Spaces and special characters are URL-encoded. These paths serve the file directly and enforce the same file and ancestor-folder passwords as the browser.
Renaming or moving a file or parent folder changes its readable URL. Existing /files/<id>/<filename> links continue to work after renaming or moving; IDs remain internal storage keys. Browser sharing links resolve the file’s current folder and still open the preview or password prompt.
The app reserves /api, /files, /_bun, and /index.html, and built frontend assets take precedence over uploaded paths. Files in reserved namespaces use ID-based download links automatically. Uploaded names are resolved through database entries and never used as physical filesystem paths.
While signed in, drag a file or folder onto a folder card or folder row to move it inside. Drop onto an ancestor in the breadcrumb to move it back up, or onto Library / All files in the sidebar to move it to the root. Both grid and list views support dragging. Files dropped from your computer continue to upload into the current folder.
For touch or keyboard use, open the item’s menu and choose Move to folder, browse to the destination, and select Move here. Existing names are never overwritten, and folders cannot be moved into themselves or their descendants. The destination’s inherited passwords apply immediately; an item’s own password remains attached. Moving out of a protected folder removes that inherited password requirement, so use an item password if it should remain protected everywhere.
Use Bun, not npm:
bun install
bun run setup
bun devOpen http://localhost:3000. bun run setup prints your generated credentials. To run the optimized production app locally:
bun run build
bun startValidation:
bun run typecheck
bun test
bun run buildBoth library.sqlite (metadata, password hashes, sessions) and files/ (uploaded content) live under /data in Docker, or ./data locally. Back up the entire volume together, preferably while the app is stopped, so the SQLite database and file content remain consistent. Keep .env in your backup separately. docker compose down retains the volume; docker compose down -v deletes all uploads and metadata.
Uploads with duplicate names are rejected rather than overwritten. Files are stored using generated IDs and never interpreted as filesystem paths. Deleting a folder permanently deletes its descendants. Visitor unlocks and owner sessions use HttpOnly, SameSite cookies, and API mutations check browser origins. Login and unlock attempts are rate-limited globally per account/item (15 attempts per 15 minutes); proxy headers cannot bypass the limit. A popular protected share can hit that limit, so use a strong password and share it with your intended audience.
Password protection controls access; stored files are not encrypted at rest. The app supports a single owner and a single running instance per data volume. Do not scale replicas against the SQLite volume. The app never serves its data directory as a public static directory. Configure your proxy to route all file requests through the application.