# ZPan puts an S3 bucket behind a Cloudflare Workers control plane

> ZPan turns an S3-compatible bucket you already own into a web drive, a file-sharing host and an image bed, with the server holding auth and metadata instead of file bytes. The Docker path also ships a separate Go downloader that most self-hosted drives keep outside their scope.

**saltbo/zpan** — Lightweight file hosting platform built on top of S3-compatible storage

- Repository: https://github.com/saltbo/zpan
- Website: https://zpan.space
- Stars: 2,079 · Forks: 320
- Language: TypeScript
- License: AGPL-3.0
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/saltbo-zpan

## A web drive for a bucket you already own

ZPan assumes the bucket comes first. You supply an S3-compatible endpoint, and the project supplies the browser front end, the account model and the share links. Named backends run from Cloudflare R2 and AWS S3 through Backblaze B2 to self-hosted MinIO and RustFS, with Tigris listed alongside them.

That assumption draws a hard line in the project's own comparison table. Multi net-disk aggregation is the one capability AList scores as first-class, and ZPan scores the same cell as absent, which means an AList user with twelve drives gets a different product here. Real-time document co-editing belongs to Nextcloud by the same table, and a local directory as the primary file root belongs to File Browser. ZPan claims neither, and a file browser that pretends otherwise would be guessing at the storage contract.

The narrower jobs get named tools instead of vague promises. Image hosting targets PicGo, PicList, uPic, ShareX and Flameshot, each returning a stable URL. Sharing adds password, expiration, download limits, direct links and save-to-drive flows. Every user gets a public `/u/username` page for shared files, and WebDAV mounts the same bucket for desktop clients.

## Presigned URLs keep file bytes off the control plane

The server never proxies a file. A browser asks for permission, the control plane returns a presigned object-storage URL, and the client then talks to the bucket directly, which is how uploads and downloads end up bypassing server bandwidth entirely. What stays server-side is everything that is not bytes: auth, metadata, shares, quotas, teams, WebDAV, tool integrations and admin operations.

The runtime choice follows from that split. ZPan is written against Cloudflare Workers, D1, Hono and web-standard APIs, with Docker and other runtimes added as extra targets. A process that only signs URLs and stores rows in D1 does not need a box with a public disk, a NAS kept online, or a long-running upload proxy. The header claim of Workers or Docker with direct uploads describes one control plane in two locations, not two different products.

Quotas and teams sit on the server side of the same split, which means access control survives a client that uploads straight to storage. Whatever the bucket's own credentials allow, ZPan still decides who may mint a URL.

## Starting the Docker path on port 8222

The compose file is the shortest route to a first run, and its own header comment carries the command that builds rather than pulls:

```bash
docker compose -f docker-compose.yml up -d --build
```

The app service publishes 8222, keeps its database on a named volume mounted at `/data`, and reads its auth settings from the environment:

```yaml
    ports:
      - "8222:8222"
    environment:
      - PORT=8222
      - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-}
      - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:8222}
      - DATABASE_URL=/data/zpan.db
    volumes:
      - zpan-data:/data
```

`BETTER_AUTH_SECRET` has an empty default, so filling it in before the first start is on you. The health probe is a real fetch against the API rather than a TCP poke:

```yaml
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8222/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
```

Twelve retries at five second intervals is the budget the second service waits on before it starts.

## The downloader is a second image built from cmd/

The compose file declares a `downloader` service that does not come from the TypeScript stack at all. It builds with `target: cli`, the second stage of the same Dockerfile, which cross-compiles a Go module with `CGO_ENABLED=0` from a golang 1.25 base while the frontend builder runs on node 24-slim, so the arm64 image never passes through QEMU:

```dockerfile
FROM node:24-slim AS builder
FROM --platform=$BUILDPLATFORM golang:1.25 AS cli-builder
```

That container runs one subcommand and nothing else:

```yaml
  downloader:
    build:
      context: .
      target: cli
    hostname: zpan-downloader
    command: ["--config", "/data/config.yaml", "downloader", "up"]
```

Every setting is a prefixed variable. `ZPAN_SERVER_URL` points at the sibling service by its compose name, `ZPAN_DOWNLOADER_ENGINE` defaults to `auto`, finished files land in `/data/downloads` and state in `/data/state`, and `ZPAN_DOWNLOADER_SEED_CACHE_LIMIT` defaults to `10GB`. Two published ports follow, one over TCP and one over UDP. TypeScript is the primary language in the repository, and the Go module under `cmd/` is a separate tool that the Dockerfile comment says should not force a full server rebuild.

## ZPAN_BT_PORT only moves the host side

Both published ports are written with two variables, which is where a skim of the compose file misses the asymmetry:

```yaml
      - "${ZPAN_BT_PORT:-6881}:${ZPAN_DOWNLOADER_BT_LISTEN_PORT:-6881}/tcp"
      - "${ZPAN_BT_PORT:-6881}:${ZPAN_DOWNLOADER_BT_LISTEN_PORT:-6881}/udp"
```

`ZPAN_DOWNLOADER_BT_LISTEN_PORT` is passed into the container through the environment block, where it decides the port the downloader listens on. `ZPAN_BT_PORT` appears nowhere in that block; it exists only on the left side of the mapping, so it moves the host-side published port and nothing inside the container. Change the first and peers reach a different port on the host while the downloader keeps its default; change the second and the listener moves while the host port stays put. Both default to 6881, so an untouched compose file publishes one host port twice, over each protocol.

## Six build targets and one migration step

package.json is where the deployment matrix becomes concrete. One Vite frontend build is reused by five server bundles: `build:node` for the Node and Docker path, then `build:lambda` in CommonJS, `build:vercel`, `build:netlify` and `build:azure`. Each tsup call marks `better-sqlite3` and `@libsql/client` external, so the database driver resolves at runtime instead of being bundled, which is what lets one bundle open a local SQLite file under `/data`.

Local work has two doors. The `vite dev` script starts the frontend, while `scripts/dev-node.mjs` runs with `--env-file=.dev.vars`, and the repository ships `.dev.vars.example` as the template for it. Migrations belong to Drizzle, generated by `drizzle-kit generate` into the `migrations/` directory, with the D1 variants wired into wrangler commands.

A local Node run therefore mirrors the Workers runtime rather than the container, which matters when a behaviour differs between them.

## Upgrade means a migration and a Workers publish

The deploy script is a single line: build, migrate the production D1 database, publish.

```bash
pnpm build && pnpm db:migrate:d1:prod && wrangler deploy
```

Schema and code ship together, and that line carries no rollback step, so reversing a bad migration is not covered by the command that applies it. Docker takes a different route: the compose file carries no migrate step, so an upgrade delivers whatever the image was built with.

Two facts frame the adoption decision itself. The last push landed on 2026-10-01, package.json carries version 2.9.0 and that value matches the v2.9.0 tag dated 2026-08-30, while the earlier 2026-08-03 point in the same series carries 2.8.2. The license field in package.json reads `AGPL-3.0-only`, with the full text in the `LICENSE` file at the repository root, and anyone who plans to modify the control plane and serve it over a network has to read that text first rather than assume a permissive default.

## What the comparison table leaves unverified

The grid against Cloudreve, AList, Nextcloud, Seafile and File Browser reads as a claim rather than a measurement. Every mark in every column was scored by this project, and nothing in the repository records how a competitor cell was established, so treat the cross-project rows as positioning.

Two cells matter most to a buyer. Dedicated sync clients are marked Planned, an admission that no desktop sync story exists yet, and `V2_ROADMAP.md` sits at the repository root as the place to look for that work. WebDAV is marked present for every project except File Browser, so a WebDAV requirement narrows nothing at all.

The Workers walkthrough is thinner still. Its section is headed Recommended, promises zero server management and a free tier that covers personal use, and the visible steps stop at forking the repository. No page counts, quotas or limits appear for that free tier, and nothing visible explains how a separately deployed downloader reaches an instance running on Workers instead of on the Docker network where the compose file puts it.

## Conclusion

ZPan fits when you already hold an S3 bucket and want a browser-facing drive, share links and an image bed without running a VPS, since uploads leave the client through presigned URLs and the server keeps control-plane state only. It does not fit real-time co-editing, drive aggregation or desktop sync, three rows its own table scores against Nextcloud, AList and Seafile. Before deploying, confirm that your bucket credentials reach the runtime and that your plan covers the downloader path you intend to use.

## FAQ

### What storage backends can ZPan connect to?

Only S3-compatible object storage. Cloudflare R2, AWS S3, Backblaze B2, MinIO, RustFS and Tigris are the named examples, and you supply the bucket and its credentials yourself.

### Do I need a VPS or NAS to run ZPan?

No. The primary target is Cloudflare Workers with D1, and Docker is an alternative path. Remote download is the exception: the compose downloader service reaches ZPan over the Docker network, and the README does not explain how a standalone downloader connects to a Workers deployment.

### How do I run ZPan locally while developing?

Two scripts in package.json: one runs the Vite dev server, the other runs scripts/dev-node.mjs with --env-file=.dev.vars. The repository ships .dev.vars.example as the template for those local environment values.

### Is there a desktop sync client for ZPan?

Not yet. The comparison table marks dedicated sync clients as Planned for ZPan, while only Nextcloud and Seafile are marked as having them.

### What license is ZPan released under?

AGPL-3.0. The license field in package.json reads AGPL-3.0-only, and the full text sits in the LICENSE file at the repository root.

## Sources

- [License: AGPL-3.0](https://github.com/saltbo/zpan/blob/main/LICENSE)
- [Project website](https://zpan.space)
- [README](https://github.com/saltbo/zpan/blob/main/README.md)
- [Releases](https://github.com/saltbo/zpan/releases)
- [saltbo/zpan on GitHub](https://github.com/saltbo/zpan)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/saltbo-zpan
