Files
denkfabrik-li da7eb6f67d Publish the quickstart on loopback, since it trusts any proxy
compose.example.yaml does two things that are each fine alone and unsafe
together:

    ports:
      - "8080:80"            # Docker binds 0.0.0.0 unless told otherwise
    environment:
      TRUSTED_PROXIES: "*"   # believe the X-Forwarded-For of whoever connects

Behind a proxy that appends the header, "*" is correct and harmless --
Symfony strips the peer and takes the real client the proxy appended. The
example never gets there. It publishes the container on every interface,
so a visitor can reach port 8080 themselves, and then *they* are the peer
the application has been told to trust. `X-Forwarded-For: 203.0.113.9`
makes request()->ip() return exactly that.

What that costs, all of it on the signed-out surface:

  - the login lockout, keyed on `email|ip` in LoginRequest::throttleKey()
  - throttle:6,1 on register, password-email, password-reset, two-factor
  - throttle:30,1 on share-link, public-browse, public-comment
  - the download log, the activity log, and the `ip_address` recorded on
    guest comments -- which FileComments::post calls "the one handle that
    makes spam actionable"

Rotate the header and every one of them counts a different attacker.

The project's own test states the primitive: TrustedProxiesTest sets
trustedproxy.proxies = '*', sends X-Forwarded-For from a *direct* client,
and asserts the address is taken.

The documentation has always qualified "*" correctly -- .env.example says
it is "only safe when nothing but the proxy can reach the app", and
dockerhub-overview.md repeats it. The example file is what did not meet
its own precondition, and it is the file the Docker Hub description tells
a first-time reader to copy.

Publishing on 127.0.0.1 restores the precondition: a proxy on the host, or
in this compose file, still reaches it; nothing off the machine does. This
repository's own compose.yaml already publishes Adminer that way, for the
same reason.

The two settings are now documented as a pair in all three places that
carry them, including what to do when the proxy is on another host: bind
to the interface it arrives from and name that address in TRUSTED_PROXIES
instead of "*".

DOCKER.md's health-check command changes with it -- it told the reader to
curl <host-ip>:8080 from the same machine, which the new binding does not
answer. It now says 127.0.0.1:8080.

No test: this is packaging and prose. `docker compose config` parses the
edited file.
2026-08-29 00:05:03 +02:00

12 KiB

ProjectSend

Release Docker pulls Image size GitHub stars Discord License

ProjectSend

Share files with your clients, from your own server.

ProjectSend is a self-hosted application for getting files to the people you work with. You upload what you want to send, choose exactly who can see it, and each client signs in to their own private page to download it.

No public link passed around by email, no third-party service holding your clients' documents, no per-seat pricing. It runs on your server, and the files stay there.

This is the official image for the Community edition — free software under the GPL v2 or later.

The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity

The dashboard — what is in the installation, and what has been happening in it. More screenshots are in the README.


Quick start

Save this as compose.yaml, change the passwords and APP_URL, then docker compose up -d.

name: projectsend

services:
  app:
    image: projectsend/projectsend:2
    restart: unless-stopped
    ports:
      # Put a TLS-terminating proxy in front of this in any real install.
      # Bound to loopback because TRUSTED_PROXIES below is "*": the
      # application then believes the X-Forwarded-For of whoever connects,
      # so nobody but the proxy may be able to.
      - "127.0.0.1:8080:80"
    environment:
      APP_URL: https://files.example.com
      APP_ENV: production
      APP_DEBUG: "false"

      DB_HOST: db
      DB_DATABASE: projectsend
      DB_USERNAME: projectsend
      DB_PASSWORD: change-me-database

      REDIS_HOST: redis
      CACHE_STORE: redis
      SESSION_DRIVER: redis
      QUEUE_CONNECTION: redis

      # Required whenever anything sits between your visitors and this
      # container — including the reverse proxy you should be running.
      # "*" trusts whoever connects, so it goes together with the loopback
      # binding above: change one and you have to change the other.
      TRUSTED_PROXIES: "*"

      # Optional: uncomment these — with a password of your own — to create
      # the first administrator unattended and skip the setup screen. Left
      # commented, the setup screen creates it instead. Ignored once any
      # user exists.
      # ADMIN_NAME: Administrator
      # ADMIN_EMAIL: admin@example.com
      # ADMIN_PASSWORD: change-me-admin
    volumes:
      # Every uploaded file lives here, along with the generated APP_KEY.
      - storage:/var/www/html/storage
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql:8.4
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: projectsend
      MYSQL_USER: projectsend
      MYSQL_PASSWORD: change-me-database
      MYSQL_ROOT_PASSWORD: change-me-root
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
      interval: 5s
      timeout: 5s
      retries: 20

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis-data:/data

volumes:
  storage:
  db-data:
  redis-data:

Then open APP_URL — the setup screen creates your administrator account. Uncomment ADMIN_EMAIL and ADMIN_PASSWORD above, with a password of your own, to create it unattended instead.

The first start is slower than later ones: the container waits for MySQL to accept connections before it migrates the database. That wait is normal, not a failure.


Tags

Tag Moves
2.2.0 Never — an exact release
2.2 With each patch on the 2.2 line
2 With each release on the 2.x line
latest With each release

Pin to 2 for unattended updates that stay on a compatible line, or to an exact version if you would rather decide when to move.

Built for linux/amd64 and linux/arm64.


What is in the image

One container runs the whole application under supervisord: nginx, php-fpm, two queue workers and the scheduler. You do not need a separate worker or a cron entry.

The second worker exists because building a zip download can take a long time, and it runs on its own queue so that one large archive cannot hold up every notification email behind it. Both are in the image; there is nothing to configure.

nginx is part of the image rather than an option, because protected downloads are served with X-Accel-Redirect — an fpm-only image would leave every download broken unless you reproduced the config exactly.

You need to bring:

  • MySQL 8.0 or newer (we test on 8.4 LTS). Required.
  • Redis — recommended, but optional. Without it, set CACHE_STORE, SESSION_DRIVER and QUEUE_CONNECTION to database and drop the redis service; sessions, cache and jobs then live in MySQL.

The image packages the same release artifact as the published zip, byte for byte. It does not build the application at image build time.


Environment

Everything is read from the environment. The values below are the ones that matter; anything a Laravel application understands works too.

Variable Notes
APP_URL The address your users reach, not the published port. Download links and password-reset emails are built from it.
APP_ENV production
APP_DEBUG false in production
APP_KEY Generated on first boot and kept on the storage volume. Set it explicitly only if you manage secrets elsewhere — and never change it on a running install: it decrypts existing data.
APP_TIMEZONE Defaults to UTC
TRUSTED_PROXIES Comma-separated addresses/CIDRs, or *. See below.
DB_HOST DB_PORT DB_DATABASE DB_USERNAME DB_PASSWORD MySQL connection
REDIS_HOST REDIS_PORT REDIS_PASSWORD Redis connection
CACHE_STORE SESSION_DRIVER QUEUE_CONNECTION redis or database
MAIL_MAILER MAIL_HOST MAIL_PORT MAIL_USERNAME MAIL_PASSWORD MAIL_ENCRYPTION MAIL_FROM_ADDRESS Fallback mail settings. Easier to configure from System → Settings → Email once you are signed in — it has a "send test" button.
FILESYSTEM_DISK local (default) or s3
AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION AWS_BUCKET AWS_USE_PATH_STYLE_ENDPOINT For S3-compatible storage
ADMIN_NAME ADMIN_EMAIL ADMIN_PASSWORD Creates the first administrator unattended. Ignored once any user exists.

Behind a reverse proxy

Set TRUSTED_PROXIES. Without it every visitor appears to come from the proxy: the login rate limiter treats all of your users as one attacker, and the download log records the proxy's address instead of the real one. * is correct when nothing but your proxy can reach the container.


Data and backups

Two things must outlive the container: your MySQL database, and the volume mounted at /var/www/html/storage. Uploaded files live on that volume, and so does the generated .env holding APP_KEY — losing the key makes the SMTP and LDAP passwords stored in your database undecryptable even if the rest of the backup is perfect.

DOCKER.md is the guide: how to move both onto host paths you chose, the backup and restore commands, and how to move the whole installation to another server. Read it before you put real files in ProjectSend, not after.


Updating

docker compose pull
docker compose up -d

The container migrates the database itself on boot — the same php artisan projectsend:update a manual install runs — so there is no separate step. Take a database dump first anyway: migrations move forwards, not backwards.


What it does

For the people you send to

  • A private area per client, showing only what has been shared with them
  • Sign in with an email address, with optional two-factor authentication
  • Search, filter and sort; download one file, several as a zip, or a whole folder
  • Optional comments on a file, so questions live next to the thing they are about
  • Email notifications when something new arrives, in their own language

For you

  • Resumable uploads that survive a dropped connection, so large files actually arrive
  • Folders, categories and client groups
  • Share with one client, a whole group, or publicly — with an expiry date or a download limit
  • Thumbnails and previews for images and documents
  • Storage quotas per client, and custom fields for the details you keep on them
  • A full activity log and download history: who got what, and when

For the installation

  • Roles and permissions for your own team, so an uploader is not an administrator
  • LDAP, social sign-in, or plain email and password
  • Themes for the client-facing pages and for outgoing email
  • 16 languages
  • A REST API with scoped tokens and generated OpenAPI docs
  • Privacy controls, including GDPR-grade account erasure with a grace period
  • Local disk, S3-compatible storage, or Google Cloud Storage

Coming from ProjectSend Legacy?

The previous generation lives on at projectsend/legacy. This is a rebuild rather than an upgrade, so moving across is an import: install fresh, then bring your old site in with the migration tool — accounts, clients, groups, categories, folders, files and history. It never writes to your old install, and any run can be undone with a single command.

MIGRATING-FROM-V1.md is the guide, and it has a section for this image specifically: the migration runs from the projectsend:migrate:* commands here rather than from a screen, because this image ships the application already built and carries no Composer of its own.


Documentation and support

Found a security issue? Please report it privately through GitHub's security advisories rather than opening a public issue.

License

GNU General Public License v2, or (at your option) any later version. Commercial licenses are available for organizations that cannot work under copyleft terms — see LICENSING.md.