# Running ProjectSend with Docker: where your data lives

Docker is the recommended way to run ProjectSend, and this page is about the one part of it that
bites people: **your files and your database do not belong to the containers, and you should be
able to prove it.** Containers are meant to be thrown away and rebuilt — that is the whole point of
them — so an upgrade, a crash, or a bad `docker compose` command must never be able to take your
data with it.

Read this before you put real files in ProjectSend, not after.

> Getting started with Docker in the first place is covered in [README](README.md#development).
> Installing without Docker, on a plain PHP server, is [INSTALL.md](INSTALL.md).

---

## The three things that matter

Everything ProjectSend cannot regenerate lives in exactly three places:

| What | Where it is by default | Losing it means |
|---|---|---|
| **The database** | A Docker *named volume*, `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
| **Uploaded files** | `storage/app/files/` in the project directory | The files your clients downloaded — gone |
| **`.env`** | The project directory | `APP_KEY`, without which saved SMTP and LDAP passwords cannot be decrypted |

You do not have to work out which of these you have from memory. **The dashboard's System panel
reports where your uploaded files actually live** — a host directory, a Docker volume (named), or
the container's own filesystem — and warns you about the last two. The database is the one thing it
cannot check: it runs in its own container, and the only way for PHP to see inside that one would be
to hand it the Docker socket, which would turn any vulnerability in the application into root on
your server. That half is on you, and it is what the backup section below is for.

Two things you may be surprised to find you do **not** need to protect:

- **Redis** (`projectsend_redis-data`) holds sessions, the cache and the job queue. Losing it signs
  everyone out and drops any not-yet-sent emails or half-built zips. Annoying; not data loss.
- **Parts of `storage/app/files/`** are derived, not precious: `zips/` (built downloads, deleted
  automatically after a day), `thumbnails/` and `previews/` (rebuilt on demand the next time
  somebody looks at a file). They sit in the same directory as the real uploads, so the simplest
  thing is to back up all of it and not think about which is which.

## The good news, and the one command to fear

Named volumes are already outside the container lifecycle. `docker compose down`,
`docker compose up --build`, deleting and recreating every container — none of those touch
`projectsend_db-data`. Upgrading does not lose your database, and never did.

The command that *does* destroy it is:

```sh
docker compose down -v        # ← the -v deletes the named volumes
```

That flag exists to clean up a development machine. On a real installation it deletes your entire
database in about a second, with no confirmation. The same goes for `docker volume prune` and
`docker system prune --volumes` when the stack happens to be down.

So the actual problem with the default setup is not fragility, it is **invisibility**: your
database is somewhere under `/var/lib/docker/volumes/`, which means most people never back it up
and would not know where to look. The rest of this page fixes that.

---

## Putting the data where you chose

Bind-mount both to real paths on the host, so your data sits somewhere you picked, somewhere you
can see in `ls`, and somewhere your existing backup tool already knows about.

### 1. Make the directories

```sh
sudo mkdir -p /srv/projectsend/files /srv/projectsend/mysql

# The app containers run as uid 1000 by default (the WWWUSER build argument).
# If you set WWWUSER to something else in .env, use that instead.
sudo chown -R 1000:1000 /srv/projectsend/files
```

Leave `/srv/projectsend/mysql` owned by root — the MySQL image sets its own ownership the first
time it starts.

### 2. Create `compose.override.yaml`

Next to `compose.yaml`. Docker Compose reads this file automatically and merges it on top, so you
never edit the tracked `compose.yaml` and nothing you write here is lost on the next update.

```yaml
services:
  # All four app containers must see the same files directory. Missing one of
  # them is the classic mistake: uploads land in one place and downloads are
  # served from another, so every download 404s. `web` is the one people
  # forget — nginx serves the bytes itself, from
  # /var/www/html/storage/app/files/, so it needs the mount just as much as
  # the container that wrote them.
  app:
    volumes:
      - /srv/projectsend/files:/var/www/html/storage/app/files
  web:
    volumes:
      - /srv/projectsend/files:/var/www/html/storage/app/files
  worker:
    volumes:
      - /srv/projectsend/files:/var/www/html/storage/app/files
  scheduler:
    volumes:
      - /srv/projectsend/files:/var/www/html/storage/app/files

  db:
    volumes:
      - /srv/projectsend/mysql:/var/lib/mysql
```

Check the result before applying it — this prints the fully merged configuration:

```sh
docker compose config
```

### 3. Move the data you already have

**Skip this on a brand-new installation.** There is nothing to move; go straight to step 4.

Stop everything first. Copying a database out from under a running MySQL is how you get a backup
that restores into a corrupt table.

```sh
docker compose down          # no -v
```

Files, which are already on the host inside the project directory:

```sh
sudo rsync -a storage/app/files/ /srv/projectsend/files/
sudo chown -R 1000:1000 /srv/projectsend/files
```

The database, which is in the named volume. A throwaway container is the tidy way to reach inside
one:

```sh
docker run --rm \
    -v projectsend_db-data:/from \
    -v /srv/projectsend/mysql:/to \
    alpine sh -c 'cd /from && cp -a . /to'
```

(`projectsend_db-data` is the volume's real name — the `db-data` from `compose.yaml` prefixed with
the project name. `docker volume ls` will confirm it.)

### 4. Start, and check

```sh
docker compose up -d
```

Then prove it worked rather than assuming: log in and check the dashboard's System panel — **Files
stored on** should now read *Host directory*, and the Docker-volume warning should be gone. Then
open a file, **download it**, and upload a new one; confirm the new upload appears in
`/srv/projectsend/files/` on the host. A download that returns nothing means one of the four
containers is missing the mount from step 2.

Once you are satisfied, and not before, you can reclaim the old volume:

```sh
docker volume rm projectsend_db-data
```

---

## Backing up

Bind mounts make your data visible. They do not make it backed up.

### The database

**Do not back up the MySQL directory by copying it while the database is running.** A file-level
copy of a live data directory is not a snapshot — it is a set of files captured at slightly
different moments, and it may restore into something subtly broken. Use a dump:

```sh
docker compose exec -T db \
    mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \
        --single-transaction --routines --triggers \
        projectsend > projectsend-$(date +%F).sql
```

`--single-transaction` is what makes this safe on a running database: the dump sees one consistent
moment in time without locking anybody out.

### The files

```sh
rsync -a /srv/projectsend/files/ /your/backup/location/files/
```

Ordinary files, no special handling. Restoring means copying them back and fixing ownership
(`chown -R 1000:1000`).

### `.env`

Copy it somewhere safe, once, and again whenever you change it. It is a few hundred bytes and it
holds `APP_KEY` — lose that and the SMTP and LDAP passwords stored in your database become
undecryptable, even though the rest of the backup is perfect.

### Restoring

```sh
docker compose up -d db
docker compose exec -T db mysql -u root -p"${DB_ROOT_PASSWORD:-root}" projectsend < projectsend-2026-08-08.sql
sudo rsync -a /your/backup/location/files/ /srv/projectsend/files/
sudo chown -R 1000:1000 /srv/projectsend/files
docker compose up -d
```

**Test this at least once, on a machine that is not your live one.** A backup nobody has ever
restored is a hypothesis, not a backup.

---

## Upgrading

With the data outside the containers, an upgrade touches only the containers:

```sh
docker compose down            # again: no -v
git pull                       # or unpack the new release over the directory
docker compose up -d --build
```

The app container migrates the database itself on boot and verifies its reference data, so there is
no separate migration step. Take a database dump first anyway — migrations move forwards, not
backwards, and the one time you skip it will be the time you want it.

---

## Moving to another server

This is the payoff for everything above, and it is worth doing once deliberately so you know it
works:

1. Dump the database and copy `/srv/projectsend/`, `.env` and the dump to the new machine.
2. Install Docker, put the project directory in place, restore both as described under
   [Restoring](#restoring).
3. Point DNS at the new machine, and update `APP_URL` in `.env` if the address changed.

No export tool, no vendor involvement, nothing that only works while the old machine is alive.
That is the property worth protecting, and the reason this page exists.
