Self-host Backu with Docker

This guide sets up a small production-style Backu installation with Docker Compose. It includes:

  • Backu's gateway and web app
  • PostgreSQL for accounts, sessions, and server identity
  • MinIO for S3-compatible object storage
  • Caddy for HTTPS certificates and reverse proxying
  • persistent storage, health checks, backups, and upgrades

The examples are written for an IT administrator running Backu on one Linux server. You can swap Caddy or MinIO for services you already operate.

Before you begin

Backu currently suits a trusted team or household. Anyone who can reach the sign-up page and verify an email address can create an account, and verified accounts can access the machines connected to this Backu server. There is not yet an invite-only mode or per-user machine isolation.

Put the service on a private network or behind your existing access-control layer unless open registration is acceptable. Only connect machines whose files may be accessed by every verified user on this server.

What you need

  • A Linux server with a supported version of Docker Engine and the Docker Compose plugin
  • Git, so you can build the current Backu image
  • At least 2 CPU cores, 4 GB of memory, and enough disk space for PostgreSQL, uploads, and backups
  • Two stable DNS names pointing to the server, for example:
    • backu.example.com for Backu
    • storage.example.com for the MinIO S3 API
  • Inbound TCP ports 80 and 443 open to the networks that will use Backu
  • Outbound internet access for container builds, email delivery, and Iroh peer discovery and relay connections
  • A Resend API key and a verified sender address

The storage name must be reachable from every machine that uploads files. Backu gives clients short-lived MinIO URLs, so a Docker-only name such as minio:9000 will not work as BACKU_S3_ENDPOINT.

Use a stable Backu hostname from the start. Passkeys are tied to the hostname, and changing it later can prevent existing passkeys from signing in.

1. Prepare the server

Create a working directory and clone Backu into it:

sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/backu
cd /opt/backu
git clone https://github.com/oscartbeaumont/backu.git source

The repository is currently private, so Git will ask for credentials from an account with access.

Create four strong, unrelated secrets. A password manager is the best place to keep the long-term copy.

openssl rand -hex 24
openssl rand -hex 32
openssl rand -hex 32
openssl rand -hex 32

Create /opt/backu/.env and replace every example value:

BACKU_DOMAIN=backu.example.com
STORAGE_DOMAIN=storage.example.com

# Protects machine enrollment. Give this only to machine administrators.
BACKU_DAEMON_TOKEN=replace-with-the-first-random-value

# PostgreSQL account used only by Backu.
POSTGRES_PASSWORD=replace-with-the-second-random-value

# Account verification and recovery email.
BACKU_RESEND_API_KEY=re_replace_with_your_resend_key
BACKU_EMAIL_FROM="Backu <[email protected]>"

# MinIO administration. Backu does not use this account.
MINIO_ROOT_USER=minio-admin
MINIO_ROOT_PASSWORD=replace-with-the-third-random-value

# The restricted MinIO account used by Backu.
BACKU_S3_ACCESS_KEY_ID=backu-app
BACKU_S3_SECRET_ACCESS_KEY=replace-with-the-fourth-random-value
BACKU_S3_BUCKET=backu

Lock down the file because it contains credentials:

chmod 600 /opt/backu/.env

Do not commit this file to Git or copy its values into tickets and chat messages.

2. Configure MinIO permissions

MinIO should keep the bucket private. Backu only needs access to its own bucket and multipart uploads.

Create /opt/backu/minio-policy.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": ["arn:aws:s3:::backu"]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:AbortMultipartUpload",
        "s3:GetObject",
        "s3:ListMultipartUploadParts",
        "s3:PutObject"
      ],
      "Resource": ["arn:aws:s3:::backu/*"]
    }
  ]
}

This policy assumes the bucket is named backu. If you change BACKU_S3_BUCKET, update both resource names in the policy too.

3. Create the Docker Compose stack

Create /opt/backu/compose.yaml:

services:
  backu:
    image: backu-server:local
    build:
      context: ./source
      dockerfile: apps/server/Dockerfile
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      minio-init:
        condition: service_completed_successfully
    environment:
      BACKU_BIND: 0.0.0.0:3000
      DATABASE_URL: postgres://backu:${POSTGRES_PASSWORD}@postgres:5432/backu
      BACKU_PUBLIC_URL: https://${BACKU_DOMAIN}
      BACKU_TOKEN: ${BACKU_DAEMON_TOKEN}
      BACKU_RESEND_API_KEY: ${BACKU_RESEND_API_KEY}
      BACKU_EMAIL_FROM: ${BACKU_EMAIL_FROM}
      BACKU_S3_ENDPOINT: https://${STORAGE_DOMAIN}
      BACKU_S3_PATH_STYLE: "true"
      BACKU_S3_REGION: us-east-1
      BACKU_S3_BUCKET: ${BACKU_S3_BUCKET}
      BACKU_S3_ACCESS_KEY_ID: ${BACKU_S3_ACCESS_KEY_ID}
      BACKU_S3_SECRET_ACCESS_KEY: ${BACKU_S3_SECRET_ACCESS_KEY}
    volumes:
      - backu-data:/data

  postgres:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_DB: backu
      POSTGRES_USER: backu
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U backu -d backu"]
      interval: 5s
      timeout: 5s
      retries: 10

  minio:
    image: quay.io/minio/minio:latest
    command: server /data --console-address ":9001"
    restart: unless-stopped
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
    volumes:
      - minio-data:/data
    # The admin console is only reachable from the Docker host.
    ports:
      - "127.0.0.1:9001:9001"

  minio-init:
    image: quay.io/minio/mc:latest
    depends_on:
      - minio
    restart: "no"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
      BACKU_S3_BUCKET: ${BACKU_S3_BUCKET}
      BACKU_S3_ACCESS_KEY_ID: ${BACKU_S3_ACCESS_KEY_ID}
      BACKU_S3_SECRET_ACCESS_KEY: ${BACKU_S3_SECRET_ACCESS_KEY}
    volumes:
      - ./minio-policy.json:/config/minio-policy.json:ro
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        until mc alias set local http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}"; do sleep 2; done
        mc mb --ignore-existing "local/$${BACKU_S3_BUCKET}"
        mc admin user add local "$${BACKU_S3_ACCESS_KEY_ID}" "$${BACKU_S3_SECRET_ACCESS_KEY}" || true
        mc admin policy create local backu-rw /config/minio-policy.json || true
        mc admin policy attach local backu-rw --user "$${BACKU_S3_ACCESS_KEY_ID}"

  caddy:
    image: caddy:2
    restart: unless-stopped
    depends_on:
      - backu
      - minio
    environment:
      BACKU_DOMAIN: ${BACKU_DOMAIN}
      STORAGE_DOMAIN: ${STORAGE_DOMAIN}
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config

volumes:
  backu-data:
  postgres-data:
  minio-data:
  caddy-data:
  caddy-config:

For predictable upgrades, replace the latest MinIO tags with a release you have tested and update them deliberately.

4. Add HTTPS

Create /opt/backu/Caddyfile:

{$BACKU_DOMAIN} {
  encode zstd gzip
  reverse_proxy backu:3000
}

{$STORAGE_DOMAIN} {
  reverse_proxy minio:9000
}

Caddy will request public TLS certificates automatically. Both DNS names must resolve to the server, and ports 80 and 443 must reach Caddy.

If you use an existing load balancer or reverse proxy, send the Backu hostname to port 3000 and the storage hostname to MinIO port 9000. Keep both backend ports private. HTTPS is required for passkeys outside localhost.

5. Start Backu

Build and start the stack:

cd /opt/backu
docker compose build --pull backu
docker compose up -d
docker compose ps

The first build can take several minutes. Follow startup logs with:

docker compose logs -f backu caddy minio-init

Check the public health endpoint:

curl --fail --silent https://backu.example.com/api/health

A healthy response looks like this:

{"status":"ok","machines":0,"emailConfigured":true}

If emailConfigured is false, check both Resend variables before inviting users. Do not enable BACKU_DEV_EMAIL_LINKS in production: it exposes account links in API responses and logs.

Open https://backu.example.com/dash/, create the first account, register a passkey, and follow the email verification link.

6. Connect a machine

Install or build the Backu daemon on the machine that owns the files. Set the same enrollment secret used by the server, then name each folder you want to expose:

export BACKU_DAEMON_TOKEN='your-machine-enrollment-secret'

cargo run -p backu-cli -- daemon \
  --server-endpoint https://backu.example.com \
  --name "Finance file server" \
  --root "Reports=/srv/reports" \
  --token "$BACKU_DAEMON_TOKEN"

Run the daemon as a dedicated operating-system user and grant it read access only to the folders Backu should expose. Keep its data directory persistent because it contains the machine identity. The web app should show the machine as soon as the daemon connects.

The daemon and server use Iroh/QUIC. They try a direct encrypted path and can fall back to relay infrastructure, so you normally do not need to forward a separate inbound peer-to-peer port.

Day-two operations

Back up the right data

Back up the state in all three storage services:

  • PostgreSQL contains accounts, sessions, one-time tokens, and the signing and Iroh server identities.
  • backu-data contains locally synced photos.
  • minio-data contains uploaded objects.

Also back up /opt/backu/.env, compose.yaml, Caddyfile, and minio-policy.json in your secrets-management or configuration-backup system.

Use pg_dump for a transactionally consistent PostgreSQL backup. For example:

docker compose exec -T postgres pg_dump -U backu -d backu -Fc > backu-postgres.dump

Also snapshot backu-data and minio-data, or stop their writers briefly while taking file-level copies. Test the PostgreSQL dump and volume restores together on another host. A backup is only useful once you know it restores.

The PostgreSQL backup is especially important. Recreating the database changes signing keys and the Iroh server identity, invalidates sessions, and can require machines to be paired again.

Upgrade

Read the release notes and take a backup first. Then rebuild from the version you intend to run:

cd /opt/backu/source
git fetch --tags origin
git checkout <tested-tag-or-commit>

cd /opt/backu
docker compose build --pull backu
docker compose up -d
curl --fail --silent https://backu.example.com/api/health

Pinning a tested tag or commit makes rollbacks and audits much easier than following the repository's default branch.

Monitor

At minimum, monitor:

  • https://backu.example.com/api/health for a successful response
  • free space for Docker volumes and the host filesystem
  • PostgreSQL availability, storage growth, and backup freshness
  • container restart counts and Backu, Caddy, and MinIO logs
  • certificate-renewal errors
  • whether emailConfigured remains true

The machines value in the health response is the number of machines currently known to the running server. Alerting on it can be useful, but remember that planned maintenance or sleeping devices may reduce it.

Open the MinIO console safely

The Compose file binds the MinIO console to localhost only. Reach it through an SSH tunnel instead of publishing it to the internet:

ssh -L 9001:127.0.0.1:9001 admin@backu-server

Then open http://localhost:9001 and sign in with the MinIO administrator credentials from .env.

Troubleshooting

Passkey registration fails

Confirm you are using the exact URL in BACKU_PUBLIC_URL, that it is HTTPS, and that its certificate is trusted. Check that a proxy is not changing the browser-visible hostname. If you set BACKU_PASSKEY_RP_ID, it must be the same hostname or a registrable parent domain you control.

Account creation says email is not configured

Both BACKU_RESEND_API_KEY and BACKU_EMAIL_FROM must be present. The sender domain must be verified in Resend. Check docker compose logs backu for a rejected email request.

Uploads cannot reach object storage

Test https://storage.example.com from the uploading machine, not just from the Docker host. Confirm that BACKU_S3_ENDPOINT uses that public or private-network DNS name and that path-style access is enabled for this MinIO setup.

Do not make the bucket public. Backu signs short-lived upload requests with the restricted MinIO account.

The web page returns a gateway error

Make sure BACKU_BIND=0.0.0.0:3000 is present. Without it, the server listens only inside its own container. Then inspect:

docker compose ps
docker compose logs --tail=200 backu caddy

Backu cannot connect to PostgreSQL

Check that DATABASE_URL is present and that its password matches POSTGRES_PASSWORD. Then inspect docker compose ps postgres and docker compose logs postgres backu. Backu runs embedded migrations at startup, so its database user must own the backu database and be allowed to create and alter tables and indexes.

A machine cannot connect

Check that its --server-endpoint is the public Backu URL and that its --token exactly matches BACKU_TOKEN on the server. Confirm outbound HTTPS and UDP are allowed. Preserve the daemon's data directory across restarts so it keeps the same identity.

Replace MinIO with an existing S3 service

You can remove the minio and minio-init services and point Backu at an existing private bucket. Set:

BACKU_S3_ENDPOINT=https://s3.example.com
BACKU_S3_REGION=your-region
BACKU_S3_BUCKET=your-private-bucket
BACKU_S3_ACCESS_KEY_ID=your-access-key
BACKU_S3_SECRET_ACCESS_KEY=your-secret-key
BACKU_S3_PATH_STYLE=true

Path-style access is common for self-hosted S3 services. AWS S3 and some hosted providers use virtual-host style instead; omit BACKU_S3_PATH_STYLE in that case. Give the credentials only multipart-upload and object permissions for the selected bucket.