Brushpass is a self-hosted secure file transfer tool. Authenticated users upload files to GCP Cloud Storage and receive a shareable URL plus a one-time-shown download token. Anyone with the URL and token can download the file — no account required. Files can have optional TTLs with active cleanup. An admin panel provides file management, expiration control, download metrics, and user management.
Brushpass uses Chainguard throughout the container and dependency supply chain.
The Docker build uses two Chainguard Container images:
FROM cgr.dev/barretta/node:25-dev AS builder # build stage — includes gcc, make, python3 for native addons
FROM cgr.dev/barretta/node:25-slim AS runner # runtime stage — minimal, distroless-styleBoth images are rebuilt nightly from source with zero known CVEs at release time and ship with Sigstore signatures and SBOMs. The multi-stage build means the final runtime image contains only the Node.js runtime and application files — no compiler toolchain, no package manager, no shell.
All 9 production npm dependencies are available in the Chainguard Libraries for JavaScript registry at their exact pinned versions — verified against the barretta org registry using chainctl auth token --audience=libraries.cgr.dev:
| Package | Version | In Chainguard registry |
|---|---|---|
next |
16.2.1 | ✅ |
react / react-dom |
19.2.4 | ✅ |
better-sqlite3 |
12.8.0 | ✅ |
@google-cloud/storage |
7.19.0 | ✅ |
next-auth |
5.0.0-beta.30 | ✅ |
bcryptjs |
3.0.3 | ✅ |
busboy |
1.6.0 | ✅ |
@noble/hashes |
2.0.1 | ✅ |
Chainguard Libraries rebuilds every package from its original source repository in a hardened SLSA L2 build environment rather than downloading pre-compiled artifacts from the public npm registry. Each package ships with Sigstore signatures and SLSA provenance attestations. This eliminates the class of supply-chain attacks where malware is injected into a registry artifact after the legitimate source code was written — ~99% of known malicious npm packages by that vector.
Integration status: The direct dependencies are all confirmed in the registry. Wiring the build to install from libraries.cgr.dev/javascript/ requires a repository manager (Artifactory/Nexus) as a proxy to handle fallback for any pre-release transitive dependencies not yet indexed — without one, npm has no native fallback mechanism. See the spike research for full details. You can verify any installed package with chainctl libraries verify $(npm config get cache).
- Requirements
- Quick start (local dev)
- Configuration reference
- GCS setup
- OIDC / SSO setup
- First admin user
- Running in production
- Deploy to GCP with Terraform
- Scheduled cleanup
- User management API
- Node.js 18+ (tested on 20 LTS and 22)
- npm (comes with Node)
- A GCP project with a Cloud Storage bucket
- GCS credentials — either Application Default Credentials (ADC) or a service account JSON key
# 1. Install dependencies (run from the repo root)
npm install
# 2. Copy and fill in environment variables
cp .env.example .env
# edit .env — see Configuration reference below
# 3. Run the dev server
npm run devOpen http://localhost:3000. You will be redirected to /login.
Note: The dev server uses Turbopack by default. If you hit eval() errors running inside a GSD or similar agent environment, use
npm run startafter a production build instead — the production server does not use eval().
All configuration is via environment variables. Copy .env.example to .env for local development. In production, set these in your process environment or secrets manager — do not commit .env to version control.
| Variable | Description |
|---|---|
GCS_BUCKET |
Name of the GCS bucket where uploaded files are stored. Required at startup — the server will not start without it. |
AUTH_SECRET |
Secret used to sign Auth.js JWT session tokens. Generate with openssl rand -base64 32. Must be the same across all instances if you run multiple. |
AUTH_URL |
The canonical base URL of your deployment, e.g. https://files.example.com. Used by Auth.js v5 to construct redirect URLs and validate origins. Required in production. |
DATABASE_PATH |
Path to the SQLite database file, e.g. ./data/fileshare.db. The directory is created automatically. Defaults to ./data/fileshare.db if unset. |
CLEANUP_SECRET |
Bearer token that protects GET /api/cleanup. Generate with openssl rand -base64 32. Keep this secret — anyone with it can trigger bulk deletion. |
The app uses the Google Cloud Node.js client which supports two credential modes:
| Variable | Description |
|---|---|
GOOGLE_APPLICATION_CREDENTIALS |
Path to a service account JSON key file, e.g. /etc/secrets/sa.json. If set, this takes precedence over ADC. |
| (none) | If GOOGLE_APPLICATION_CREDENTIALS is not set, the client uses Application Default Credentials. On GCE/GKE/Cloud Run this is the instance service account. Locally, run gcloud auth application-default login. |
All three variables must be set together. Setting only some of them disables OIDC and logs a warning at startup.
| Variable | Description |
|---|---|
AUTH_OIDC_ISSUER |
OIDC issuer URL, e.g. https://accounts.google.com or https://your-org.okta.com. Must expose a /.well-known/openid-configuration endpoint. |
AUTH_OIDC_CLIENT_ID |
Client ID from your IdP application registration. |
AUTH_OIDC_CLIENT_SECRET |
Client secret from your IdP application registration. |
AUTH_OIDC_ADMIN_DOMAIN |
Email domain whose users automatically receive ["upload", "admin"] on first OIDC sign-in (e.g. example.com). Optional — leave unset to require manual permission grants. |
| Variable | Description |
|---|---|
NEXTAUTH_URL |
Older Auth.js v4 name for AUTH_URL. Accepted for compatibility. Prefer AUTH_URL in new deployments. |
gcloud storage buckets create gs://YOUR_BUCKET_NAME \
--location=US \
--uniform-bucket-level-accessThe bucket should not be public. The app streams files server-side — clients never access GCS directly.
The identity running the app needs the following role on the bucket:
roles/storage.objectAdmin
This covers read, write, delete, and rename (copy + delete) operations. If you prefer least-privilege:
| Permission | Used for |
|---|---|
storage.objects.create |
Upload |
storage.objects.get |
Download streaming |
storage.objects.delete |
Admin delete, cleanup job, rename |
storage.objects.update |
Rename (rewrite metadata) |
Option A — Application Default Credentials (recommended for GCP-hosted deployments)
On GCE, GKE, or Cloud Run, attach a service account to the instance/pod/service with the permissions above. No credential file needed — the client library picks them up automatically.
For local development:
gcloud auth application-default login
# or
gcloud auth application-default login --impersonate-service-account=sa@project.iam.gserviceaccount.comOption B — Service account key file
# Create a service account
gcloud iam service-accounts create fileshare \
--display-name="Fileshare app"
# Grant bucket access
gcloud storage buckets add-iam-policy-binding gs://YOUR_BUCKET_NAME \
--member="serviceAccount:fileshare@YOUR_PROJECT.iam.gserviceaccount.com" \
--role="roles/storage.objectAdmin"
# Create and download a key
gcloud iam service-accounts keys create sa.json \
--iam-account=fileshare@YOUR_PROJECT.iam.gserviceaccount.comThen set in your environment:
GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json
Update GCS_BUCKET in your environment and restart. Files already in the old bucket are not migrated automatically — they remain in the old bucket and downloads will fail for those files. If you need to migrate, copy the objects with gcloud storage cp before switching.
The app uses a generic OIDC provider — any IdP that exposes an OpenID Connect discovery document at {issuer}/.well-known/openid-configuration will work. Tested with Google, Okta, and Keycloak. Should work with any compliant IdP (Azure AD, Auth0, Dex, etc.).
The redirect URI to register is:
{AUTH_URL}/api/auth/callback/oidc
For example: https://files.example.com/api/auth/callback/oidc
AUTH_OIDC_ISSUER=https://accounts.google.com # or your IdP's issuer URL
AUTH_OIDC_CLIENT_ID=your-client-id
AUTH_OIDC_CLIENT_SECRET=your-client-secretWhen all three are set, the login page shows a "Sign in with SSO" button below the username/password form.
OIDC sign-in upserts a user record in SQLite (auth_provider='oidc', no password hash). By default new OIDC users receive no permissions — they can access download pages but not upload or admin routes.
Two ways to grant permissions:
Option A — Domain auto-promotion (recommended for internal deployments)
Set AUTH_OIDC_ADMIN_DOMAIN to your organization's email domain:
AUTH_OIDC_ADMIN_DOMAIN=example.comUsers whose email matches that domain automatically receive ["upload", "admin"] on their first OIDC sign-in. Subsequent logins do not change permissions — so permissions can be downgraded manually without being re-granted on next login.
Option B — Manual grant via admin UI
Leave AUTH_OIDC_ADMIN_DOMAIN unset. After the user signs in once (creating their record), go to /admin/users, find the user, and assign permissions.
Google:
- Issuer:
https://accounts.google.com - Configure an OAuth 2.0 Web Application credential in Google Cloud Console
- Add the callback URL to "Authorized redirect URIs"
Okta:
- Issuer:
https://your-org.okta.com(or a custom authorization server URL) - Create an OIDC Web Application
- Add the callback URL to "Sign-in redirect URIs"
- Enable "Client Credentials" or "Authorization Code" grant type
Keycloak:
- Issuer:
https://your-keycloak/realms/your-realm - Create a Client with "openid-connect" protocol
- Set Access Type to "confidential"
- Add the callback URL to "Valid Redirect URIs"
Partial config warning: If only 1 or 2 of the three OIDC vars are set, the server logs a warning at startup and disables the OIDC button entirely. It does not fail to start.
There is no seed script. The first admin user must be created via the API before any user can log in to the admin UI.
Bootstrap the first admin user:
# Generate a bcrypt hash of your chosen password
node -e "
const bcrypt = require('bcryptjs');
bcrypt.hash('your-password', 10).then(h => console.log(h));
"
# Insert directly into SQLite
sqlite3 /path/to/your/fileshare.db \
"INSERT INTO users (username, password_hash, permissions) VALUES ('admin', '<paste-hash>', '[\"admin\",\"upload\"]');"After that, log in at /login and use the admin UI at /admin/users to create additional users.
Alternatively, use the API directly (if you can authenticate somehow — e.g. via a temporary user created in the DB):
curl -X POST https://files.example.com/api/admin/users \
-H "Content-Type: application/json" \
-b "session-cookie=..." \
-d '{
"username": "alice",
"password": "her-password",
"permissions": ["upload"]
}'Permission values:
"upload"— can upload files"admin"— full admin access (implies upload access)
Users can have both: ["admin", "upload"].
npm run buildThis produces an optimized Next.js build in .next/. The build uses webpack (Turbopack is disabled for production builds to avoid path-resolution issues in certain hosting environments).
npm run startThis starts the Next.js production server on port 3000 by default.
To use a different port:
PORT=8080 npm run startAt minimum, set these before starting:
export GCS_BUCKET=your-bucket-name
export AUTH_SECRET=$(openssl rand -base64 32)
export AUTH_URL=https://files.example.com
export DATABASE_PATH=/var/lib/fileshare/fileshare.db
export CLEANUP_SECRET=$(openssl rand -base64 32)
# If using a service account key:
export GOOGLE_APPLICATION_CREDENTIALS=/etc/secrets/sa.jsonCreate /etc/systemd/system/fileshare.service:
[Unit]
Description=Brushpass
After=network.target
[Service]
Type=simple
User=fileshare
WorkingDirectory=/opt/fileshare
ExecStart=/usr/bin/node_modules/.bin/next start --webpack
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=GCS_BUCKET=your-bucket-name
Environment=AUTH_SECRET=your-auth-secret
Environment=AUTH_URL=https://files.example.com
Environment=DATABASE_PATH=/var/lib/fileshare/fileshare.db
Environment=CLEANUP_SECRET=your-cleanup-secret
Environment=GOOGLE_APPLICATION_CREDENTIALS=/etc/secrets/sa.json
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable fileshare
systemctl start fileshare
journalctl -fu filesharenpm install -g pm2
pm2 start npm --name fileshare -- run start
pm2 save
pm2 startup # follow the printed instructions to enable on bootOr with an ecosystem file (ecosystem.config.js):
module.exports = {
apps: [{
name: 'fileshare',
script: 'npm',
args: 'run start',
cwd: '/opt/fileshare',
env: {
NODE_ENV: 'production',
PORT: 3000,
GCS_BUCKET: 'your-bucket-name',
AUTH_SECRET: 'your-auth-secret',
AUTH_URL: 'https://files.example.com',
DATABASE_PATH: '/var/lib/fileshare/fileshare.db',
CLEANUP_SECRET: 'your-cleanup-secret',
},
}],
};pm2 start ecosystem.config.jsThe app listens on HTTP. Put nginx in front for TLS termination:
server {
listen 443 ssl;
server_name files.example.com;
ssl_certificate /etc/letsencrypt/live/files.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/files.example.com/privkey.pem;
# Increase for large file uploads — adjust to your needs
client_max_body_size 500M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for streaming downloads — disable buffering
proxy_buffering off;
proxy_read_timeout 300s;
}
}
server {
listen 80;
server_name files.example.com;
return 301 https://$host$request_uri;
}
AUTH_TRUST_HOST: If Auth.js logs warnings about untrusted hosts behind the proxy, setAUTH_TRUST_HOST=truein your environment. This tells Auth.js to trust theX-Forwarded-Hostheader from the proxy.
The SQLite database file must persist across restarts. Ensure the directory exists and is writable by the process user:
mkdir -p /var/lib/fileshare
chown fileshare:fileshare /var/lib/fileshareThe app creates the database file and runs schema migrations automatically on first start.
The terraform/ directory contains the complete infrastructure definition for deploying to GCP Cloud Run. A single script handles everything: Docker build, Artifact Registry setup, all GCP resources, AUTH_URL configuration, and the initial admin bootstrap.
- Terraform >= 1.6
gcloudCLI authenticated:gcloud auth application-default login- Docker with
buildxsupport (for cross-platform ARM → AMD64 builds)
cd terraform
cp terraform.tfvars.example terraform.tfvars
# Fill in: project_id, container_image, bucket names, bootstrap_admin_pass./deploy.shThis single script:
- Configures Docker auth for Artifact Registry
- Imports the AR repo into Terraform state if it already exists
- Builds and pushes the image (
linux/amd64) - Runs
terraform initandterraform apply - Patches
AUTH_URLonto the service post-creation (viagcloud run services update) - Executes the bootstrap job to create the initial admin account
Use ./deploy.sh --plan to see what Terraform would change without applying.
After verifying you can log in at the service URL, remove the temporary admin credentials:
gcloud secrets delete fileshare-admin-user --project=YOUR_PROJECT --quiet
gcloud secrets delete fileshare-admin-pass --project=YOUR_PROJECT --quiet
terraform state rm google_secret_manager_secret.admin_user
terraform state rm google_secret_manager_secret_version.admin_user
terraform state rm google_secret_manager_secret.admin_pass
terraform state rm google_secret_manager_secret_version.admin_passThen remove the admin_user/admin_pass resource blocks from terraform/secrets.tf and the ADMIN_USER/ADMIN_PASS env blocks from the bootstrap job in terraform/cloudrun.tf.
The exact commands are also printed at the end of each
deploy.shrun.
For code-only changes (no infrastructure updates), use the faster redeploy script — it skips Terraform entirely:
./redeploy.shFor infrastructure changes (new env vars, IAM, scaling, etc.), re-run the full deploy:
./deploy.shmax-instances=1is enforced at the Terraform level. SQLite on GCS FUSE does not support concurrent writers — do not increase this unless you migrate to Cloud SQL.- Terraform state contains sensitive values (generated secrets). The default backend is local; switch to a GCS backend for team use (instructions in
terraform/main.tf). - OIDC: set
oidc_issuer,oidc_client_id, andoidc_client_secretinterraform.tfvarsand re-apply. All three must be non-empty to enable. The exact redirect URI to register with your IdP is printed as theoidc_callback_urloutput after apply. Optionally setoidc_admin_domainto auto-grant upload+admin to users from that email domain on first sign-in.
The cleanup job deletes expired files from GCS and removes their records from SQLite. It does not run automatically — you must call it on a schedule.
Endpoint: GET /api/cleanup
Auth: Authorization: Bearer {CLEANUP_SECRET}
curl -H "Authorization: Bearer $CLEANUP_SECRET" https://files.example.com/api/cleanup
# Response: {"deleted": 3, "errors": []}Schedule with cron:
# Run cleanup every hour
0 * * * * curl -sf -H "Authorization: Bearer YOUR_SECRET" https://files.example.com/api/cleanup >> /var/log/fileshare-cleanup.log 2>&1Schedule with systemd timer:
/etc/systemd/system/fileshare-cleanup.service:
[Unit]
Description=Fileshare cleanup job
[Service]
Type=oneshot
ExecStart=curl -sf -H "Authorization: Bearer YOUR_SECRET" https://files.example.com/api/cleanup/etc/systemd/system/fileshare-cleanup.timer:
[Unit]
Description=Run fileshare cleanup hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now fileshare-cleanup.timerThe cleanup job is idempotent — running it more frequently than necessary is safe. Errors deleting individual files (e.g. already-deleted GCS objects) are logged but do not abort the job; the response body includes an errors array with per-file failures.
The admin UI at /admin/users covers most user management needs. For scripting or bootstrapping, the REST API is available to any session with admin permission.
GET /api/admin/userscurl -X POST https://files.example.com/api/admin/users \
-H "Content-Type: application/json" \
-b "your-session-cookie" \
-d '{
"username": "alice",
"password": "secure-password",
"permissions": ["upload"]
}'Permissions: ["upload"], ["admin"], or ["admin", "upload"].
Returns 409 if the username already exists.
PATCH /api/admin/users/{id}
# Body: { "username"?: string, "password"?: string, "permissions"?: string[] }DELETE /api/admin/users/{id}Returns 409 if you attempt to delete your own account.
- Update
GCS_BUCKETin your environment. - Restart the server.
- Note: files uploaded to the old bucket are not migrated. Their download links will break until the objects are manually copied to the new bucket with the same key names.
Changing AUTH_SECRET invalidates all existing sessions — every logged-in user will be signed out on their next request. Rotate it like any session signing key: update the value and restart.
Update the value in your environment and restart. Update any cron jobs or timers that use the old value.
- Stop the server.
- Copy the existing database file to the new path:
cp old/fileshare.db new/fileshare.db - Update
DATABASE_PATHand restart.
Moving the database without copying it will start fresh with an empty database — all file records and users will be lost (the GCS objects remain, but there will be no metadata to serve them).
To enable: set all three AUTH_OIDC_* vars and restart. The SSO button appears on the login page automatically.
To disable: unset (or leave empty) any one of the three vars and restart. The SSO button disappears. Existing sessions created via OIDC remain valid until they expire.