Can I self-host Box?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called Seafile. It takes one prompt, a 2048 MB VPS, and about 110 minutes. That is $14 a month you stop paying Box — $168 a year on the Personal Pro plan.

Why people pay for Box

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Box sells a filing cabinet that other companies are willing to audit. The sync and the sharing are table stakes; what the money buys is retention policies, legal holds, e-signature and governance sitting on the same files, plus an operator who answers when a regulator asks where a document went. Personal Pro is the small end of that: 100 GB, a 5 GB upload ceiling, and somebody else keeping the lights on.

Box plans and list prices
PlanList priceWhat it buys
Individualfree10 GB of storage and a 250 MB limit on any one file.
Personal Prothe plan this page prices against$14/mo100 GB of storage and a 5 GB file size limit. Around $120 a year on the annual plan, which the page presents as roughly $10 a month.
Business Starter$7/mo per seatMinimum 3 users, 100 GB shared, 2 GB file size limit. $5 per user per month on the annual plan, the figure Box advertises in its own page title.
Business$20/mo per seatMinimum 3 users, unlimited storage, 5 GB file size limit. About $15 per user per month billed annually.
Business Plus$33/mo per seatMinimum 3 users, 15 GB file size limit. About $25 per user per month billed annually.
Enterprise$47/mo per seatMinimum 3 users, 50 GB file size limit. About $35 per user per month billed annually.
Enterprise Plusquote onlyAnnual billing only. Box's pricing page says to switch to annual billing to see a figure, and publishes none for month-to-month.

Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: low

Replaced by Seafile

One project, named before the prompt, so you know what you are about to install.

File sync and share on a box you own, with versioned libraries and clients that move only the blocks that changed.

The closest thing to Box's core loop that one person can operate. Seafile splits every file into deduplicated blocks, so its desktop clients move only what changed rather than re-uploading a 2 GB video because you renamed it, and a library is versioned and shareable by link the way a Box folder is. The trade is narrow and honest: the community edition has no content search inside documents, no audit log and no folder-level permission matrix, which are exactly the features Box's business tiers exist to sell.

What else we looked at

Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.

  1. Seafileour pickONE EVENING— setup effort 2 of 4

    File sync and share on a box you own, with versioned libraries and clients that move only the blocks that changed.

    The closest thing to Box's core loop that one person can operate. Seafile splits every file into deduplicated blocks, so its desktop clients move only what changed rather than re-uploading a 2 GB video because you renamed it, and a library is versioned and shareable by link the way a Box folder is. The trade is narrow and honest: the community edition has no content search inside documents, no audit log and no folder-level permission matrix, which are exactly the features Box's business tiers exist to sell.

  2. NextcloudONE WEEKEND— setup effort 3 of 4

    Files, calendars and contacts on hardware you control, with the desktop and mobile clients pointed at it instead of somebody else's cloud.

    The right answer if what you actually miss about Box is the surrounding platform rather than the syncing. Nextcloud brings calendar, contacts, an office suite, e-signature and a large app ecosystem onto the same files, which is far closer to Box's product than Seafile is. It is also a much larger thing to run and slower on big libraries, because it stores files as files and tracks them in the database rather than moving blocks. Pick it for breadth, pick Seafile for sync that keeps up.

The swap

You're paying

Box

$14/mo · $168/yr

is replaced by

You'd run

Seafile

ONE EVENING · ~110 min to running · 2048 MB RAM

Box Personal Pro · vendor list price · checked 2026-08-06 · source · confidence: low

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
10 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~110 min1–3 hours, through the first backup

The prompt

Two paths to the same Seafile: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

331 lines · 14,964 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install Seafile Community Edition 13.0.25 on that server, reachable at https://<DOMAIN>, behind
the existing Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user for both once and stop until they
answer. Say this when you ask: `<DOMAIN>` becomes `SEAFILE_SERVER_HOSTNAME`, and Seahub rebuilds
every share link and upload address out of it at every start, so it is expensive to change later.
Its A record must already point here. `<ADMIN_EMAIL>` is the address the one administrator account
is created under; no SMTP is configured, so it never receives mail.

Seafile needs 2048 MB of RAM available and 10 GB free on /srv, upstream's floor for the community
edition, and that 10 GB is the install rather than the files. All three images are multi-arch.

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both and stop. Do not install
and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a certificate for a
name that does not resolve, and failed attempts count against a rate limit.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/seafile /srv/seafile/backups
sudo install -d -m 750 /srv/seafile/data
sudo install -d -m 700 /srv/seafile/mysql
ls -la /srv/seafile
```

Assert: `backups` owned by the login user, `data` and `mysql` by root. Leave those two alone and
read them with `sudo ls`. The Seafile container runs as root and fills `data` with `conf`,
`seafile-data`, `seahub-data` and `logs` on first boot; MariaDB chowns `mysql` to its own uid, and
one already chowned makes it refuse to initialise.

## 3. Secrets

Five secrets, all generated here on the server. Do not print any of them, do not repeat them in your
summary, and keep them out of every log line. Hex rather than base64 for all five: two travel inside
database connection strings and one is typed into a login form by a human.

```bash
umask 077
cat > /srv/seafile/.env <<EOF
SEAFILE_SERVER_HOSTNAME=<DOMAIN>
INIT_SEAFILE_ADMIN_EMAIL=<ADMIN_EMAIL>
TIME_ZONE=Etc/UTC
INIT_SEAFILE_MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
SEAFILE_MYSQL_DB_PASSWORD=$(openssl rand -hex 32)
JWT_PRIVATE_KEY=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
INIT_SEAFILE_ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/seafile/.env
umask 022
ls -l /srv/seafile/.env
```

Assert: mode `-rw-------` and the login user's name twice. Compose reads this file for the `${...}`
substitutions in compose.yml whenever it runs from /srv/seafile, so the values reach the containers
without it being mounted. Upstream wants `JWT_PRIVATE_KEY` at 32 characters or more and it gets 64;
changing it later invalidates every session. The `INIT_` values are read on the first start only.

## 4. compose.yml

```bash
cat > /srv/seafile/compose.yml <<'EOF'
# Seafile Community Edition · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://manual.seafile.com/13.0/setup/setup_ce_by_docker/
#   variable reference . https://manual.seafile.com/13.0/config/env/
#   reverse proxy ...... https://manual.seafile.com/13.0/setup/use_other_reverse_proxy/
#
# Three services. Upstream's own deployment starts five, adding a Caddy and the
# SeaDoc editor; this box already runs Caddy, and ENABLE_SEADOC false is
# upstream's documented way to drop the editor. SEAFILE_SERVER_PROTOCOL is https
# because Caddy terminates TLS here: Seahub rebuilds SERVICE_URL and
# FILE_SERVER_ROOT from it at every start, so http would put an http upload
# address on an https page. Only 8140 is published, on loopback. Digests read
# 2026-08-06; all three publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:10.11.18@sha256:de61fed4a40d3842f3ee09944ba52792156cfd9adf489b2cc670fc6ded28df8d
    container_name: seafile-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/seafile/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 5s
      retries: 10
    # No `ports:` at all: 3306 only exists on the compose network.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: seafile-redis
    restart: unless-stopped
    # A password, which upstream leaves off; $$ defers expansion to the container.
    command:
      - /bin/sh
      - -c
      - exec redis-server --requirepass "$$REDIS_PASSWORD" --save "" --appendonly no
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # No `ports:` at all: 6379 never leaves the compose network.

  seafile:
    image: seafileltd/seafile-mc:13.0.25@sha256:90c1aaa08731116750cd7ce16cbc6afe0c26006433002d3c7215a5f4254ec244
    container_name: seafile
    restart: unless-stopped
    volumes:
      - /srv/seafile/data:/shared
    environment:
      SEAFILE_MYSQL_DB_HOST: db
      SEAFILE_MYSQL_DB_USER: seafile
      SEAFILE_MYSQL_DB_PASSWORD: ${SEAFILE_MYSQL_DB_PASSWORD}
      INIT_SEAFILE_MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      SEAFILE_MYSQL_DB_CCNET_DB_NAME: ccnet_db
      SEAFILE_MYSQL_DB_SEAFILE_DB_NAME: seafile_db
      SEAFILE_MYSQL_DB_SEAHUB_DB_NAME: seahub_db
      # Redis, because Seafile 13 stopped shipping memcached in Docker.
      CACHE_PROVIDER: redis
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
      SEAFILE_SERVER_HOSTNAME: ${SEAFILE_SERVER_HOSTNAME}
      SEAFILE_SERVER_PROTOCOL: https
      TIME_ZONE: ${TIME_ZONE}
      # Read on the first start only, to create the one account there is.
      INIT_SEAFILE_ADMIN_EMAIL: ${INIT_SEAFILE_ADMIN_EMAIL}
      INIT_SEAFILE_ADMIN_PASSWORD: ${INIT_SEAFILE_ADMIN_PASSWORD}
      # Upstream's editor extension, which would need a container of its own.
      ENABLE_SEADOC: "false"
    ports:
      - "127.0.0.1:8140:80"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
EOF
cd /srv/seafile && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, one published port, two data mounts.

## 5. Caddy and TLS

Append the block below, with `<DOMAIN>` replaced by the real hostname, to the Caddyfile Prompt Zero
installed. Copy that file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-seafile
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Seafile · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://manual.seafile.com/13.0/setup/use_other_reverse_proxy/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. It is also
# SEAFILE_SERVER_HOSTNAME in .env, so the two stay the same string.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# No `encode`: these bytes are stored file blocks on /seafhttp.
	# Upstream's nginx sample drops the body limit, the request buffering
	# and the read timeout here; Caddy already streams and caps nothing
	# unless told to, so do not add `request_body max_size`.
	#
	# 8140 is the loopback port compose publishes, not a container port,
	# and not open in the firewall.
	reverse_proxy 127.0.0.1:8140
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-seafile, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. The desktop and mobile clients need nothing more: uploads and downloads ride /seafhttp on
the same hostname and the same 443. 8140 stays closed because compose binds it to loopback, 3306 and
6379 because neither container publishes a host port. Assert: `Status: active`, rules for 80,
443/tcp and 443/udp, and no rule mentioning 8140, 3306 or 6379.

## 7. Start and verify

First boot initialises MariaDB, creates the three databases, migrates them and seeds the one
account. That takes minutes and prints nothing for long stretches.

```bash
cd /srv/seafile
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api2/ping/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api2/ping/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api2/auth/ping/
docker compose exec -T seafile printenv SEAFILE_VERSION SEAFILE_SERVER_PROTOCOL
curl -sSL https://<DOMAIN>/accounts/login/ | grep -o '<h1 class="login-panel-hd">[^<]*</h1>'
```

Assert all five and print what you received for each. The loop ends on `200`. The ping prints
`"pong"`. `/api2/auth/ping/` prints `401`, the security assert here: the API is up and refusing a
request carrying no token. `printenv` prints `13.0.25` then `https`, the first proving the container
is the pinned tag, the second that Seahub generates https addresses. The last command prints
`<h1 class="login-panel-hd">Log In</h1>`.

If any of the five misses, stop, run `docker compose logs --tail 60 seafile` and
`docker compose logs --tail 20 db`, and name the likely earlier step: a database that never reports
healthy points at step 2, a `502` means Caddy reaches nothing on 8140. A running container is not
success.

Registration is closed: upstream ships `ENABLE_SIGNUP` off, so the account created from
`INIT_SEAFILE_ADMIN_EMAIL` is the only way in and nobody can make a second one.

STOP: tell the user to read their password with
`grep INIT_SEAFILE_ADMIN_PASSWORD /srv/seafile/.env`, put it in their password manager, sign in at
https://<DOMAIN> as `<ADMIN_EMAIL>`, create a library and upload one file. Wait. Do not continue
until they confirm the file is listed: that upload is the check that matters, because the web
interface loads fine even when the file server behind /seafhttp does not.

## 8. First backup and restore

Two artifacts: a dump of the databases, and an archive of the blocks, the generated configuration
and the files that rebuild the service around them.

```bash
cd /srv/seafile
docker compose exec -T db sh -c 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mariadb-dump -uroot --opt --all-databases' | gzip > /srv/seafile/backups/seafile-db-$(date +%F).sql.gz
sudo tar -czf /srv/seafile/backups/seafile-files-$(date +%F).tar.gz -C /srv/seafile data compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/seafile/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped: `mariadb-dump`
locks each table only while it reads it, and the password is expanded inside the container, so it
never reaches the host's process list. `--all-databases` carries the `seafile` MySQL user and its
grants, without which a restore onto an empty MariaDB gives a database Seafile cannot log in to.

A backup on the same disk as the data is not a backup, so run this one from the user's machine:

```bash
mkdir -p ~/backups/seafile
scp vps:/srv/seafile/backups/* ~/backups/seafile/
```

To restore: `docker compose down`, `sudo rm -rf /srv/seafile/data /srv/seafile/mysql`, recreate both
as in step 2, untar the files archive into /srv/seafile, `docker compose up -d db`, wait a minute for
healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mariadb -uroot'`,
`docker compose restart db` so the grants take effect, then `docker compose up -d`. What matters at
2am: Seafile keeps deduplicated blocks under `data/seafile/seafile-data` and the names and library
structure in the database, so restoring one without the other gives blocks with no names or names
with no blocks.

## 9. Updating later

Image tags are listed at https://hub.docker.com/r/seafileltd/seafile-mc/tags. Take both backups
first, then edit the image line in /srv/seafile/compose.yml to the new tag and its digest:

```bash
cd /srv/seafile
docker compose pull
docker compose up -d
docker compose logs --tail 40 seafile
```

The schema upgrade runs on the way up and can take minutes. Watch that log until it settles, then
re-run the step 7 checks. Do not skip a major version; upstream writes its upgrade notes one major
at a time.

## 10. What will probably go wrong

An upload that fails silently, looking like a broken file server rather than a configuration
mistake. Seahub does not read the protocol off the request: it builds SERVICE_URL and
FILE_SERVER_ROOT at container start from `SEAFILE_SERVER_PROTOCOL` and `SEAFILE_SERVER_HOSTNAME`, so
if either is wrong the login page loads, the library list loads, and then the browser is handed an
upload address on the wrong scheme or host and refuses it. I lost twenty minutes reading file server
logs that had nothing in them, because nothing ever reached the file server. That is why step 7
asserts `printenv` prints `https`. If uploads fail later, check those two values first.

## 11. Out of scope

- Do not add the SeaDoc editor or set `ENABLE_SEADOC` to true. It is a second container on a second
  route, and this prompt installs the file server it would plug into.
- Do not configure SMTP. Seafile works without it; the cost is invitation and password-reset mail,
  and the administrator can create accounts by hand instead.
- Do not enable the notification server or the metadata server. Each is another upstream extension
  container, and neither is needed to sync files.
- Do not switch the image to seafile-pro-mc. That edition needs a licence file and brings
  Elasticsearch with it, a different install on a bigger box.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the output but
you. If you can run Claude Code, use the other tab.

You are installing Seafile Community Edition 13.0.25 on a VPS where Prompt Zero is done: `ssh vps`
works, Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points
at the box, and `<ADMIN_EMAIL>` with the address your one administrator account will be created
under.

Read this before step 1. `<DOMAIN>` becomes `SEAFILE_SERVER_HOSTNAME`, and Seahub rebuilds every
share link and every upload address out of it each time the container starts. Changing it later
means editing .env, the Caddy site block and every link you have already sent. Pick the hostname you
intend to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line. Those two floors are upstream's stated minimum for the community
edition, and the 10 GB is the install, not your files.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute, run
`dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not resolve,
and failed attempts count against a rate limit you cannot see. If RAM is under 2048 MB, stop and
resize the box rather than continuing: three containers on a 1 GB VPS get through the database
migrations and then meet the OOM killer during the first real upload, which looks like a random
failure and is not.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/seafile /srv/seafile/backups
sudo install -d -m 750 /srv/seafile/data
sudo install -d -m 700 /srv/seafile/mysql
ls -la /srv/seafile
```

You should see: `backups` owned by you, `data` at mode `drwxr-x---` and `mysql` at `drwx------`,
both owned by root.

If you do not: leave `data` and `mysql` owned by root on purpose. The Seafile container runs as root
and fills `data` with `conf`, `seafile-data`, `seahub-data` and `logs` the first time it starts, and
the MariaDB image chowns `mysql` to its own uid. One you have already chowned to yourself makes
MariaDB refuse to initialise. You will need `sudo ls` to look inside either of them afterwards.

## 3. Secrets

Five secrets, all generated here on the server, all straight into a file only you can read. Hex
rather than base64: two of them travel inside database connection strings and one gets typed into a
login form.

```bash
umask 077
cat > /srv/seafile/.env <<EOF
SEAFILE_SERVER_HOSTNAME=<DOMAIN>
INIT_SEAFILE_ADMIN_EMAIL=<ADMIN_EMAIL>
TIME_ZONE=Etc/UTC
INIT_SEAFILE_MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
SEAFILE_MYSQL_DB_PASSWORD=$(openssl rand -hex 32)
JWT_PRIVATE_KEY=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
INIT_SEAFILE_ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 /srv/seafile/.env
umask 022
ls -l /srv/seafile/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` and
`<ADMIN_EMAIL>` on the first two lines with your real values before you paste.

Do not paste that file, any of those five values, or any command output containing them into this
chat window. The agent path never sees them; this path will hand them to a third party unless you
keep them out.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/seafile/.env` and carry on. If
the file already existed from an earlier attempt, this block has now overwritten all five, which is
fine before the containers exist and a problem afterwards: MariaDB keeps the passwords it was
created with, so changed values against an existing `mysql` directory produce an access-denied loop
in the seafile log rather than anything about passwords.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/seafile/compose.yml <<'EOF'
# Seafile Community Edition · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://manual.seafile.com/13.0/setup/setup_ce_by_docker/
#   variable reference . https://manual.seafile.com/13.0/config/env/
#   reverse proxy ...... https://manual.seafile.com/13.0/setup/use_other_reverse_proxy/
#
# Three services. Upstream's own deployment starts five, adding a Caddy and the
# SeaDoc editor; this box already runs Caddy, and ENABLE_SEADOC false is
# upstream's documented way to drop the editor. SEAFILE_SERVER_PROTOCOL is https
# because Caddy terminates TLS here: Seahub rebuilds SERVICE_URL and
# FILE_SERVER_ROOT from it at every start, so http would put an http upload
# address on an https page. Only 8140 is published, on loopback. Digests read
# 2026-08-06; all three publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:10.11.18@sha256:de61fed4a40d3842f3ee09944ba52792156cfd9adf489b2cc670fc6ded28df8d
    container_name: seafile-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/seafile/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 5s
      retries: 10
    # No `ports:` at all: 3306 only exists on the compose network.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: seafile-redis
    restart: unless-stopped
    # A password, which upstream leaves off; $$ defers expansion to the container.
    command:
      - /bin/sh
      - -c
      - exec redis-server --requirepass "$$REDIS_PASSWORD" --save "" --appendonly no
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # No `ports:` at all: 6379 never leaves the compose network.

  seafile:
    image: seafileltd/seafile-mc:13.0.25@sha256:90c1aaa08731116750cd7ce16cbc6afe0c26006433002d3c7215a5f4254ec244
    container_name: seafile
    restart: unless-stopped
    volumes:
      - /srv/seafile/data:/shared
    environment:
      SEAFILE_MYSQL_DB_HOST: db
      SEAFILE_MYSQL_DB_USER: seafile
      SEAFILE_MYSQL_DB_PASSWORD: ${SEAFILE_MYSQL_DB_PASSWORD}
      INIT_SEAFILE_MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      SEAFILE_MYSQL_DB_CCNET_DB_NAME: ccnet_db
      SEAFILE_MYSQL_DB_SEAFILE_DB_NAME: seafile_db
      SEAFILE_MYSQL_DB_SEAHUB_DB_NAME: seahub_db
      # Redis, because Seafile 13 stopped shipping memcached in Docker.
      CACHE_PROVIDER: redis
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
      SEAFILE_SERVER_HOSTNAME: ${SEAFILE_SERVER_HOSTNAME}
      SEAFILE_SERVER_PROTOCOL: https
      TIME_ZONE: ${TIME_ZONE}
      # Read on the first start only, to create the one account there is.
      INIT_SEAFILE_ADMIN_EMAIL: ${INIT_SEAFILE_ADMIN_EMAIL}
      INIT_SEAFILE_ADMIN_PASSWORD: ${INIT_SEAFILE_ADMIN_PASSWORD}
      # Upstream's editor extension, which would need a container of its own.
      ENABLE_SEADOC: "false"
    ports:
      - "127.0.0.1:8140:80"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
EOF
cd /srv/seafile && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `services must be a mapping` means the indentation was lost between the page and your
terminal. Run `rm /srv/seafile/compose.yml` and paste again in one go. A message naming a variable as
not set means step 3 did not write `.env`, or you are not in `/srv/seafile`: compose reads that file
only from the directory it runs in.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error here
takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-seafile
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Seafile · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://manual.seafile.com/13.0/setup/use_other_reverse_proxy/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. It is also
# SEAFILE_SERVER_HOSTNAME in .env, so the two stay the same string.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# No `encode`: these bytes are stored file blocks on /seafhttp.
	# Upstream's nginx sample drops the body limit, the request buffering
	# and the read timeout here; Caddy already streams and caps nothing
	# unless told to, so do not add `request_body max_size`.
	#
	# 8140 is the loopback port compose publishes, not a container port,
	# and not open in the firewall.
	reverse_proxy 127.0.0.1:8140
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-seafile /etc/caddy/Caddyfile`, reload, and
paste again. The most common cause is a `<DOMAIN>` you replaced in one place and not the other.
Caddy requests the certificate on the first request to the hostname and renews it on its own, so
there is nothing to schedule.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8140`, `3306` or `6379`.

If you do not: delete anything for those three with `sudo ufw delete allow 8140`. 8140 is bound to
127.0.0.1 by the compose file, and the database and the cache publish no host port at all, so there
is nothing a firewall rule could apply to. The desktop and mobile clients do not need another port
either: their uploads and downloads ride /seafhttp on the same hostname and the same 443. `Status:
inactive` is a different problem, because Prompt Zero left this firewall enabled, so something has
turned it off since; `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

First boot initialises MariaDB, creates the three databases, runs every migration and creates the
one account. On a small server that takes minutes and prints nothing for long stretches, which is
normal.

```bash
cd /srv/seafile
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api2/ping/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api2/ping/
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api2/auth/ping/
docker compose exec -T seafile printenv SEAFILE_VERSION SEAFILE_SERVER_PROTOCOL
curl -sSL https://<DOMAIN>/accounts/login/ | grep -o '<h1 class="login-panel-hd">[^<]*</h1>'
```

You should see, in order: the loop reaching `200`, then `"pong"`, then `401`, then two lines reading
`13.0.25` and `https`, then `<h1 class="login-panel-hd">Log In</h1>`.

If you do not: the `401` is the one worth understanding. It means the API is up and refusing a call
that carries no token, which is exactly right, so seeing it is good news. A `502` in its place means
Caddy is reaching nothing on 8140: check `docker compose ps`. If the loop never reaches `200`, run
`docker compose logs --tail 20 db` first, because a database that never reports healthy is step 2
done wrong, then `docker compose logs --tail 60 seafile`. Seahub's own log is at
/srv/seafile/data/seafile/logs/seahub.log and needs `sudo` to read. A green `docker compose ps` is
not success on its own.

Nobody else can sign up: upstream ships `ENABLE_SIGNUP` off, so the account created from
`INIT_SEAFILE_ADMIN_EMAIL` is the only way in.

Now read your password and use it, once:

```bash
grep INIT_SEAFILE_ADMIN_PASSWORD /srv/seafile/.env
```

You should see: one line. Put the value in your password manager now, and do not paste that line
here. Then open https://<DOMAIN> in a browser, sign in as `<ADMIN_EMAIL>` with that password, create
a library, and upload one file to it.

If you do not: that upload is the check that matters. The web interface loads fine even when the
file server behind /seafhttp is unreachable, so a library page that appears and an upload that
stalls at 0% is the failure mode this step exists to catch. If it stalls, go to step 10 before
changing anything.

## 8. First backup and restore

Two artifacts. The dump holds the three databases plus the database user Seafile connects as; the
archive holds the file blocks, the configuration the container generated, and the files that rebuild
the service around them.

```bash
cd /srv/seafile
docker compose exec -T db sh -c 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mariadb-dump -uroot --opt --all-databases' | gzip > /srv/seafile/backups/seafile-db-$(date +%F).sql.gz
sudo tar -czf /srv/seafile/backups/seafile-files-$(date +%F).tar.gz -C /srv/seafile data compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/seafile/backups/
```

You should see: two files, the dump a few hundred kilobytes and the archive a little larger on a
fresh install. Nothing goes offline: `mariadb-dump` locks each table only while it reads it, and the
root password is expanded by the shell inside the container, so it never appears in this machine's
process list.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `mariadb-dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error. `tar:
Permission denied` means you dropped the `sudo`: the container wrote most of `data` as root.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/seafile
scp vps:/srv/seafile/backups/* ~/backups/seafile/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/seafile/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix only
means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one test file:

```bash
cd /srv/seafile
docker compose down
sudo rm -rf /srv/seafile/data /srv/seafile/mysql
sudo install -d -m 750 /srv/seafile/data
sudo install -d -m 700 /srv/seafile/mysql
sudo tar -xzf /srv/seafile/backups/seafile-files-$(date +%F).tar.gz -C /srv/seafile data
docker compose up -d db
sleep 60
gunzip -c /srv/seafile/backups/seafile-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mariadb -uroot'
docker compose restart db
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/api2/ping/
```

You should see: no output from the import, then `"pong"`. Sign in again and confirm your test file
is still in its library. That is the whole disaster plan, proved.

If you do not: `Access denied for user 'seafile'` means the database came back without its user, so
the dump was taken without `--all-databases`. That flag is not decoration: it carries the `seafile`
MySQL account and its grants, and a restore without them gives you a database Seafile cannot log in
to. Understand what is at stake before you skip this. Seafile splits every file into deduplicated
blocks under `data/seafile/seafile-data` and keeps the filenames and library structure in the
database, so restoring one without the other leaves you blocks with no names or names with no
blocks.

## 9. Updating later

Image tags are listed at https://hub.docker.com/r/seafileltd/seafile-mc/tags. Take both backup
artifacts first, then edit the `image:` line in /srv/seafile/compose.yml to the new tag and its
digest.

```bash
cd /srv/seafile
docker compose pull
docker compose up -d
docker compose logs --tail 40 seafile
```

You should see: schema upgrade output, then the server starting, and no container restarting in a
loop.

If you do not: put the old tag and digest back and run the same three commands. The upgrade can take
several minutes on a large library, so give it time before deciding it hung. Then re-run the
`/api2/ping/` and `printenv` checks from step 7 and confirm the version matches the tag you pinned.
Do not skip a major version; upstream writes its upgrade notes one major at a time.

## 10. What will probably go wrong

An upload that fails silently, looking like a broken file server rather than a configuration
mistake. Seahub does not read the protocol off the request: it builds SERVICE_URL and
FILE_SERVER_ROOT at container start from `SEAFILE_SERVER_PROTOCOL` and `SEAFILE_SERVER_HOSTNAME`, so
if either is wrong the login page loads, the library list loads, and then the browser is handed an
upload address on the wrong scheme or host and refuses it. I lost twenty minutes reading file server
logs that had nothing in them, because nothing ever reached the file server. That is why step 7
prints those two values. If uploads fail later, check them first.

## 11. Out of scope

- Do not add the SeaDoc editor or set `ENABLE_SEADOC` to true. It is a second container on a second
  route, and this install gives you the file server it would plug into.
- Do not configure SMTP. Seafile works without it; the cost is invitation and password-reset mail,
  and you can create accounts by hand in the admin panel instead.
- Do not enable the notification server or the metadata server. Each is another upstream extension
  container, and neither is needed to store and sync files.
- Do not switch the image to seafile-pro-mc. That edition needs a licence file and brings
  Elasticsearch with it, a different install on a bigger box.

328 lines · 14,988 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install Seafile Community Edition 13.0.25, with the MariaDB and Redis it needs, under
~/selfhost/seafile, at http://localhost:8140.

## 1. Preflight

Say this before step 2 runs; it decides whether they want this install. Seafile syncs one set of
files across every device a person owns, and here only this computer reaches the server: the client
on this machine syncs, the phone in their pocket does not, and a share link lands nowhere.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. The three services need 2048 MB of RAM
available and 10 GB free on the home disk, upstream's floor for the community edition. Under
either, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/seafile/data ~/selfhost/seafile/backups
ls -la ~/selfhost/seafile
```

Assert: `data` and `backups` listed. The container runs as root and fills `data` with `conf`,
`seafile-data`, `seahub-data` and `logs` on first boot; on Linux those end up owned by root and are
read with `sudo`, while Docker Desktop maps them to the user's account. MariaDB is elsewhere: step 5
keeps it in a volume Docker manages.

## 4. Secrets

Five secrets, all generated here. Print none of them, and keep them out of your summary and every
log line. Hex, not base64: two ride inside database connection strings.

```bash
umask 077
cat > ~/selfhost/seafile/.env <<EOF
SEAFILE_SERVER_HOSTNAME=localhost:8140
INIT_SEAFILE_ADMIN_EMAIL=admin@seafile.local
TIME_ZONE=Etc/UTC
INIT_SEAFILE_MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
SEAFILE_MYSQL_DB_PASSWORD=$(openssl rand -hex 32)
JWT_PRIVATE_KEY=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
INIT_SEAFILE_ADMIN_PASSWORD=$(openssl rand -hex 24)
EOF
chmod 600 ~/selfhost/seafile/.env
umask 022
ls -l ~/selfhost/seafile/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so this runs the same on all three systems.
`admin@seafile.local` is the username the one account is created under, never a mail address, and
`JWT_PRIVATE_KEY` needs 32 characters upstream and gets 64. On Windows those mode bits are advisory:
NTFS does not enforce them, and the boundary is the account.

## 5. compose.yml

```bash
cat > ~/selfhost/seafile/compose.yml <<'EOF'
# Seafile Community Edition · the deterministic fallback for the local path.
# Authored by caniselfhostit from the upstream documentation, not copied:
#   docker install ..... https://manual.seafile.com/13.0/setup/setup_ce_by_docker/
#   variable reference . https://manual.seafile.com/13.0/config/env/
#
# Three services, all on loopback; upstream's deployment starts five, adding a
# TLS proxy and the SeaDoc editor. Nothing here is public, so ENABLE_SEADOC
# is false and the hostname is localhost:8140 over http. Paths are relative to
# ~/selfhost/seafile/ except the database, a named volume because MariaDB chowns
# its data directory to a uid Docker Desktop on Windows cannot grant on a bind
# mount. Digests read 2026-08-06, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:10.11.18@sha256:de61fed4a40d3842f3ee09944ba52792156cfd9adf489b2cc670fc6ded28df8d
    container_name: seafile-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - seafile-mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 5s
      retries: 10
    # No `ports:` anywhere below: 3306 and 6379 stay on the compose network.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: seafile-redis
    restart: unless-stopped
    # A password, which upstream leaves off; $$ defers expansion to the container.
    command:
      - /bin/sh
      - -c
      - exec redis-server --requirepass "$$REDIS_PASSWORD" --save "" --appendonly no
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}

  seafile:
    image: seafileltd/seafile-mc:13.0.25@sha256:90c1aaa08731116750cd7ce16cbc6afe0c26006433002d3c7215a5f4254ec244
    container_name: seafile
    restart: unless-stopped
    volumes:
      - ./data:/shared
    environment:
      SEAFILE_MYSQL_DB_HOST: db
      SEAFILE_MYSQL_DB_USER: seafile
      SEAFILE_MYSQL_DB_PASSWORD: ${SEAFILE_MYSQL_DB_PASSWORD}
      INIT_SEAFILE_MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      SEAFILE_MYSQL_DB_CCNET_DB_NAME: ccnet_db
      SEAFILE_MYSQL_DB_SEAFILE_DB_NAME: seafile_db
      SEAFILE_MYSQL_DB_SEAHUB_DB_NAME: seahub_db
      CACHE_PROVIDER: redis
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
      SEAFILE_SERVER_HOSTNAME: ${SEAFILE_SERVER_HOSTNAME}
      SEAFILE_SERVER_PROTOCOL: http
      TIME_ZONE: ${TIME_ZONE}
      # Read on the first start only, to create the one account there is.
      INIT_SEAFILE_ADMIN_EMAIL: ${INIT_SEAFILE_ADMIN_EMAIL}
      INIT_SEAFILE_ADMIN_PASSWORD: ${INIT_SEAFILE_ADMIN_PASSWORD}
      # Upstream's editor extension, which would need a container of its own.
      ENABLE_SEADOC: "false"
    ports:
      # Loopback only: no other device on the wifi reaches 8140.
      - "127.0.0.1:8140:80"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

volumes:
  seafile-mysql:
EOF
cd ~/selfhost/seafile && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context, so crypto in the page still works.
- No firewall rule. Nothing is published beyond loopback, so nothing to close.

8140 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not anyone. Confirm:

```bash
grep -n '127.0.0.1' ~/selfhost/seafile/compose.yml
```

Assert: one line, `- "127.0.0.1:8140:80"`. Neither backing service publishes a port.

## 7. Start and verify

First boot initialises MariaDB, creates and migrates three databases and seeds the one account.
That takes minutes and prints nothing for long stretches.

```bash
cd ~/selfhost/seafile
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8140/api2/ping/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8140/api2/ping/
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8140/api2/auth/ping/
docker compose exec -T seafile printenv SEAFILE_VERSION SEAFILE_SERVER_HOSTNAME
curl -sSL http://localhost:8140/accounts/login/ | grep -o '<h1 class="login-panel-hd">[^<]*</h1>'
```

Assert all five and print what you received. The loop ends on `200`. The ping prints `"pong"`.
`/api2/auth/ping/` prints `401`, the security assert here: the API is up and refusing a request with
no token. `printenv` prints `13.0.25` then `localhost:8140`. The last prints
`<h1 class="login-panel-hd">Log In</h1>`.

If any of the five misses, stop, run `docker compose logs --tail 60 seafile` and
`docker compose logs --tail 20 db`, and name the likely cause: a database that never reports healthy
points at step 4, where an empty password leaves MariaDB refusing to start. On
`port is already allocated`, find what holds 8140 (`lsof -nP -iTCP:8140 -sTCP:LISTEN`, or
`netstat -ano | findstr :8140`) and stop until it is freed. A running container is not success.

Nobody can sign up: upstream ships `ENABLE_SIGNUP` off, so it is the only way in.

STOP: tell the user to read their password with
`grep INIT_SEAFILE_ADMIN_PASSWORD ~/selfhost/seafile/.env`, put it in their password manager, open
http://localhost:8140, sign in as `admin@seafile.local`, create a library and upload one file, and
wait. Do not continue until they confirm the file is listed: the web interface loads fine even when
the file server behind /seafhttp does not.

## 8. First backup and restore

Two artifacts: a dump of the databases, and an archive of the blocks plus the files that rebuild
the service.

```bash
cd ~/selfhost/seafile
docker compose exec -T db sh -c 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mariadb-dump -uroot --opt --all-databases' | gzip > ~/selfhost/seafile/backups/seafile-db-$(date +%F).sql.gz
tar -C ~/selfhost/seafile -czf ~/selfhost/seafile/backups/seafile-files-$(date +%F).tar.gz data compose.yml .env
ls -lh ~/selfhost/seafile/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, and the
password is expanded inside the container. On Linux the container wrote `data` as root, so if `tar`
prints `Permission denied`, run that line again with `sudo`. `--all-databases` carries the `seafile`
MySQL user and its grants, without which a restore gives a database Seafile cannot log in to.

Both archives sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a sync folder or a USB stick,
and copy both there with `cp`; in Git Bash a Windows drive is `/d/Backups`. Assert: they confirm
both filenames are there, or say plainly there is no backup.

To restore: untar the files archive into ~/selfhost/seafile first, so compose.yml and .env are back
before any container starts, then `docker compose down -v`, the one place `-v` belongs because it
drops the old database volume, `docker compose up -d db`, wait a minute for healthy, pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T db sh -c 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mariadb -uroot'`,
`docker compose restart db`, then `docker compose up -d`. What matters at 2am: the blocks live under
`data/seafile/seafile-data` and their names in the database, so one without the other is useless.

## 9. Updating later

Image tags are listed at https://hub.docker.com/r/seafileltd/seafile-mc/tags. Back up first, then
edit the image line in compose.yml to the new tag and digest:

```bash
cd ~/selfhost/seafile
docker compose pull
docker compose up -d
docker compose logs --tail 40 seafile
```

The schema upgrade runs on the way up and takes minutes. Watch it, then re-run step 7.

## 10. What will probably go wrong

I rebooted, opened http://localhost:8140 out of habit, and got a connection refused that read like a
lost library. It was not: Docker Desktop had not started with the session, so nothing was listening
on 8140 and every file was fine and unreachable at once. `restart: unless-stopped` acts only once
the Docker daemon is up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/seafile && docker compose up -d` and give it a minute.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not change `SEAFILE_SERVER_HOSTNAME` to this machine's LAN address and do not rebind 8140 to
  0.0.0.0 so a phone can reach it. That puts a file server holding everything on every network
  they join.
- Do not add the SeaDoc editor, the notification server or SMTP. Each is another container or
  integration, and this prompt installs the file server they plug into.
compose.local.ymlthe services, pinned · local layout81 lines

authored from upstream docs, never pasted · 3,247 bytes

# Seafile Community Edition · the deterministic fallback for the local path.
# Authored by caniselfhostit from the upstream documentation, not copied:
#   docker install ..... https://manual.seafile.com/13.0/setup/setup_ce_by_docker/
#   variable reference . https://manual.seafile.com/13.0/config/env/
#
# Three services, all on loopback; upstream's deployment starts five, adding a
# TLS proxy and the SeaDoc editor. Nothing here is public, so ENABLE_SEADOC
# is false and the hostname is localhost:8140 over http. Paths are relative to
# ~/selfhost/seafile/ except the database, a named volume because MariaDB chowns
# its data directory to a uid Docker Desktop on Windows cannot grant on a bind
# mount. Digests read 2026-08-06, all multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:10.11.18@sha256:de61fed4a40d3842f3ee09944ba52792156cfd9adf489b2cc670fc6ded28df8d
    container_name: seafile-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - seafile-mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 5s
      retries: 10
    # No `ports:` anywhere below: 3306 and 6379 stay on the compose network.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: seafile-redis
    restart: unless-stopped
    # A password, which upstream leaves off; $$ defers expansion to the container.
    command:
      - /bin/sh
      - -c
      - exec redis-server --requirepass "$$REDIS_PASSWORD" --save "" --appendonly no
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}

  seafile:
    image: seafileltd/seafile-mc:13.0.25@sha256:90c1aaa08731116750cd7ce16cbc6afe0c26006433002d3c7215a5f4254ec244
    container_name: seafile
    restart: unless-stopped
    volumes:
      - ./data:/shared
    environment:
      SEAFILE_MYSQL_DB_HOST: db
      SEAFILE_MYSQL_DB_USER: seafile
      SEAFILE_MYSQL_DB_PASSWORD: ${SEAFILE_MYSQL_DB_PASSWORD}
      INIT_SEAFILE_MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      SEAFILE_MYSQL_DB_CCNET_DB_NAME: ccnet_db
      SEAFILE_MYSQL_DB_SEAFILE_DB_NAME: seafile_db
      SEAFILE_MYSQL_DB_SEAHUB_DB_NAME: seahub_db
      CACHE_PROVIDER: redis
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
      SEAFILE_SERVER_HOSTNAME: ${SEAFILE_SERVER_HOSTNAME}
      SEAFILE_SERVER_PROTOCOL: http
      TIME_ZONE: ${TIME_ZONE}
      # Read on the first start only, to create the one account there is.
      INIT_SEAFILE_ADMIN_EMAIL: ${INIT_SEAFILE_ADMIN_EMAIL}
      INIT_SEAFILE_ADMIN_PASSWORD: ${INIT_SEAFILE_ADMIN_PASSWORD}
      # Upstream's editor extension, which would need a container of its own.
      ENABLE_SEADOC: "false"
    ports:
      # Loopback only: no other device on the wifi reaches 8140.
      - "127.0.0.1:8140:80"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

volumes:
  seafile-mysql:

agent-readable mirror: /self-host/box-personal-pro.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned81 lines

authored from upstream docs, never pasted · 3,448 bytes

# Seafile Community Edition · the deterministic fallback. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://manual.seafile.com/13.0/setup/setup_ce_by_docker/
#   variable reference . https://manual.seafile.com/13.0/config/env/
#   reverse proxy ...... https://manual.seafile.com/13.0/setup/use_other_reverse_proxy/
#
# Three services. Upstream's own deployment starts five, adding a Caddy and the
# SeaDoc editor; this box already runs Caddy, and ENABLE_SEADOC false is
# upstream's documented way to drop the editor. SEAFILE_SERVER_PROTOCOL is https
# because Caddy terminates TLS here: Seahub rebuilds SERVICE_URL and
# FILE_SERVER_ROOT from it at every start, so http would put an http upload
# address on an https page. Only 8140 is published, on loopback. Digests read
# 2026-08-06; all three publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: mariadb:10.11.18@sha256:de61fed4a40d3842f3ee09944ba52792156cfd9adf489b2cc670fc6ded28df8d
    container_name: seafile-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - /srv/seafile/mysql:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 20s
      start_period: 30s
      timeout: 5s
      retries: 10
    # No `ports:` at all: 3306 only exists on the compose network.

  redis:
    image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
    container_name: seafile-redis
    restart: unless-stopped
    # A password, which upstream leaves off; $$ defers expansion to the container.
    command:
      - /bin/sh
      - -c
      - exec redis-server --requirepass "$$REDIS_PASSWORD" --save "" --appendonly no
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    # No `ports:` at all: 6379 never leaves the compose network.

  seafile:
    image: seafileltd/seafile-mc:13.0.25@sha256:90c1aaa08731116750cd7ce16cbc6afe0c26006433002d3c7215a5f4254ec244
    container_name: seafile
    restart: unless-stopped
    volumes:
      - /srv/seafile/data:/shared
    environment:
      SEAFILE_MYSQL_DB_HOST: db
      SEAFILE_MYSQL_DB_USER: seafile
      SEAFILE_MYSQL_DB_PASSWORD: ${SEAFILE_MYSQL_DB_PASSWORD}
      INIT_SEAFILE_MYSQL_ROOT_PASSWORD: ${INIT_SEAFILE_MYSQL_ROOT_PASSWORD}
      SEAFILE_MYSQL_DB_CCNET_DB_NAME: ccnet_db
      SEAFILE_MYSQL_DB_SEAFILE_DB_NAME: seafile_db
      SEAFILE_MYSQL_DB_SEAHUB_DB_NAME: seahub_db
      # Redis, because Seafile 13 stopped shipping memcached in Docker.
      CACHE_PROVIDER: redis
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
      SEAFILE_SERVER_HOSTNAME: ${SEAFILE_SERVER_HOSTNAME}
      SEAFILE_SERVER_PROTOCOL: https
      TIME_ZONE: ${TIME_ZONE}
      # Read on the first start only, to create the one account there is.
      INIT_SEAFILE_ADMIN_EMAIL: ${INIT_SEAFILE_ADMIN_EMAIL}
      INIT_SEAFILE_ADMIN_PASSWORD: ${INIT_SEAFILE_ADMIN_PASSWORD}
      # Upstream's editor extension, which would need a container of its own.
      ENABLE_SEADOC: "false"
    ports:
      - "127.0.0.1:8140:80"
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
Caddyfilethe hostname and TLS28 lines

authored from upstream docs, never pasted · 1,040 bytes

# Seafile · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://manual.seafile.com/13.0/setup/use_other_reverse_proxy/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. It is also
# SEAFILE_SERVER_HOSTNAME in .env, so the two stay the same string.

<DOMAIN> {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# No `encode`: these bytes are stored file blocks on /seafhttp.
	# Upstream's nginx sample drops the body limit, the request buffering
	# and the read timeout here; Caddy already streams and caps nothing
	# unless told to, so do not add `request_body max_size`.
	#
	# 8140 is the loopback port compose publishes, not a container port,
	# and not open in the firewall.
	reverse_proxy 127.0.0.1:8140
}
install.shthe same install, no agent172 lines

authored from upstream docs, never pasted · 8,213 bytes

#!/usr/bin/env bash
# Seafile Community Edition · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=files.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://manual.seafile.com/13.0/setup/setup_ce_by_docker/
#   https://manual.seafile.com/13.0/config/env/
#   https://manual.seafile.com/13.0/setup/use_other_reverse_proxy/
#   https://manual.seafile.com/13.0/administration/backup_recovery/
#
# Five secrets are generated here, on this machine: the MariaDB root password,
# the seafile database password, the JWT signing key, the Redis password and the
# administrator's password. All five go into /srv/seafile/.env with mode 600 and
# none of them is ever printed.
#
# DOMAIN_HOST is also SEAFILE_SERVER_HOSTNAME. Seahub rebuilds every share link
# and every upload address out of it at each start, so choose it once.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/seafile}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. files.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address the administrator account is created under"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; Seafile, MariaDB and Redis want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# data and mysql stay owned by root: the Seafile container runs as root and the
# MariaDB image chowns its own data directory to a uid of its choosing.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/data"
sudo install -d -m 700 "$APP_DIR/mysql"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the five secrets, on the server -----------------------------
#
# Hex rather than base64 for all five: two ride inside database connection
# strings and one is typed into a login form. Read them later with
#   sudo grep -E 'PASSWORD|JWT_PRIVATE_KEY' /srv/seafile/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		SEAFILE_SERVER_HOSTNAME=${DOMAIN_HOST}
		INIT_SEAFILE_ADMIN_EMAIL=${ADMIN_EMAIL}
		TIME_ZONE=Etc/UTC
		INIT_SEAFILE_MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
		SEAFILE_MYSQL_DB_PASSWORD=$(openssl rand -hex 32)
		JWT_PRIVATE_KEY=$(openssl rand -hex 32)
		REDIS_PASSWORD=$(openssl rand -hex 32)
		INIT_SEAFILE_ADMIN_PASSWORD=$(openssl rand -hex 24)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-seafile"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and 8140, 3306 and 6379 are not among them ----------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8140, 3306 and 6379 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# First boot initialises MariaDB, creates the three databases, runs every
# migration and creates the one account. That takes minutes on a small server.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api2/ping/ (first boot takes several minutes)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api2/ping/" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api2/ping/ answered ${code:-nothing}. Check: docker compose logs --tail 60 seafile"

curl -sS "https://${DOMAIN_HOST}/api2/ping/" | grep -q 'pong' \
	|| die "/api2/ping/ answered 200 without pong. Check: docker compose logs --tail 60 seafile"

# The API must refuse an unauthenticated call.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api2/auth/ping/" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and investigate."

# The running container is the pinned tag, and Seahub is generating https
# addresses rather than http ones. A wrong protocol here is the failure that
# makes uploads fail on a login page that works.
running_version="$(docker compose exec -T seafile printenv SEAFILE_VERSION | tr -d '\r')"
[ "$running_version" = "13.0.25" ] || die "the container reports version ${running_version}, not 13.0.25"
running_proto="$(docker compose exec -T seafile printenv SEAFILE_SERVER_PROTOCOL | tr -d '\r')"
[ "$running_proto" = "https" ] || die "SEAFILE_SERVER_PROTOCOL is ${running_proto}, not https. Uploads would fail."

curl -sSL "https://${DOMAIN_HOST}/accounts/login/" | grep -q 'login-panel-hd' \
	|| die "the login page did not render. Check: docker compose logs --tail 60 seafile"

# --- 7. The first backup, before day one ends --------------------------------
#
# --all-databases is deliberate: it carries the seafile MySQL user and its
# grants, without which a restore onto an empty MariaDB cannot log in.

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db sh -c 'export MYSQL_PWD="$MYSQL_ROOT_PASSWORD"; exec mariadb-dump -uroot --opt --all-databases' \
	| gzip > "$APP_DIR/backups/seafile-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/seafile-files-${STAMP}.tar.gz" -C "$APP_DIR" data compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/seafile-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/seafile-files-${STAMP}.tar.gz" ] || die "the files archive is empty"

cat <<-DONE

	Seafile is answering at https://${DOMAIN_HOST}

	  1. Your administrator account is ${ADMIN_EMAIL}. Read its password with
	       sudo grep INIT_SEAFILE_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  2. Sign in, create a library, and upload one file. That upload is the
	     check that matters: the web interface loads fine even when the file
	     server behind /seafhttp does not.
	  3. Nobody else can sign up. Upstream ships ENABLE_SIGNUP off, so the
	     account above is the only way in; add people from the admin panel.
	  4. First backup written to $APP_DIR/backups: a database dump and a
	     files archive. They are on the same disk as the data, which is not a
	     backup. Copy them somewhere else tonight, from your own machine:
	       scp vps:$APP_DIR/backups/* ~/backups/seafile/
	  5. Deleted files leave their blocks on disk until garbage collection
	     runs. That is a scheduled job you own now, not something this
	     install did for you.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Box.

  • The community edition cannot search inside your documents. Box finds a phrase in a contract; Seafile CE finds the file by its name. Full-text search, audit logs, folder-level permission matrices, S3 storage and clustering are all Professional Edition, which needs a paid licence file and Elasticsearch alongside it. If the reason you pay Box is that you can find things, read that sentence twice before you migrate.
  • The licence is a stack, not a line. Upstream states it plainly: the server core is AGPLv3, the Seahub web interface is Apache-2.0, and the desktop syncing client you will install on every machine is GPLv2. All three are open source, and the record here names the server core because that is the part this install runs.
  • Your files are blocks, and the names live in a database. Seafile deduplicates every file into content-addressed blocks under seafile-data and keeps the filenames, the libraries and the sharing in three MySQL databases. A backup that has one and not the other is not a backup: it is blocks with no names, or names with no blocks. Take the dump and the archive together, and restore both at least once.
  • Deleting does not free the disk. Blocks stay until you run Seafile's garbage collection, which is a job you schedule and own. On a library you use heavily, the disk grows for a while after you thought you cleaned up.
  • No mail, no e-signature, no retention policy, no legal hold. This install configures no SMTP, so there are no invitation or password-reset emails and you create accounts by hand. The governance features around the file are most of what Box's business tiers actually sell, and none of them is here.

Where this came from

“In general, we recommend that you have at least 2G RAM and a 2-core CPU (> 2GHz).”

  • Seafile's components ship under different licences: the server core is AGPLv3, the Seahub web interface is Apache License v2, and the desktop syncing client is GPLv2. source
  • Since Seafile 13 the recommended cache service is Redis, and memcached is no longer integrated into Seafile Docker by default. source
  • Upstream documents removing the SeaDoc editor by taking seadoc.yml out of COMPOSE_FILE and setting ENABLE_SEADOC to false, which is what leaves this install at three containers. source
  • Behind a reverse proxy other than upstream's own Caddy, the seafile service listens on port 80, and upstream's sample configuration turns off the body size limit and the request buffering and raises the read timeout so large uploads are not interrupted. source
  • Full-text search inside documents, granular folder and subfolder permissions, audit logs, S3 storage backends and clustering are Professional Edition features rather than community edition ones. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Box?

    Not Box itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Seafile. File sync and share on a box you own, with versioned libraries and clients that move only the blocks that changed. The install is one evening: 3 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 110 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Box?

    Seafile. File sync and share on a box you own, with versioned libraries and clients that move only the blocks that changed. The closest thing to Box's core loop that one person can operate. Seafile splits every file into deduplicated blocks, so its desktop clients move only what changed rather than re-uploading a 2 GB video because you renamed it, and a library is versioned and shareable by link the way a Box folder is. The trade is narrow and honest: the community edition has no content search inside documents, no audit log and no folder-level permission matrix, which are exactly the features Box's business tiers exist to sell. Also evaluated and ranked below it: Nextcloud. The prompt on this page installs Seafile only. Seafile is AGPL-3.0-only-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Box?

    2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Seafile itself is free and AGPL-3.0-only-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Box Personal Pro, $14/mo — $168 a year.

  • How hard is it really?

    ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the Seafile install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Seafile on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Seafile on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Seafile exists to keep the same files on every device you own, and on your own computer it can only serve the one it is running on, so the phone in your pocket cannot sync and a share link you send lands nowhere. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.