PhotoBlad runs as two containers: the Server, which is the web app and the API, and the Indexer, which scans folders, stores backups and makes every write to your library. One Compose command starts them. The Compose files are in the deploy folder of the repository, and a .env file there holds your settings. Commands here are written for docker compose; with Podman, write podman compose and the rest is the same. The repository’s own guides go further: deploy/README.md, deploy/caddy/README.md and deploy/tailscale/README.md. The repository is private for now.
PhotoBlad’s containers were built and run with Podman, and the Caddy overlay with a real Caddy. Real Docker, a real tailnet, a real certificate from Let’s Encrypt, a real router and a real phone haven’t been tried yet: What has been tested says exactly what was and wasn’t.
What you need
- A computer that stays on, with a 64-bit Intel, AMD or ARM processor. The Dockerfile supports
linux/amd64andlinux/arm64. - Docker with Compose, or Podman with
podman-compose. - Git, and access to the repository. It’s private for now: Getting started says more. Nothing is published to a registry yet, so Compose builds both images on your machine from the source. The first build takes a few minutes and downloads the .NET base images and packages.
- Free disk space for the two images and their build, and room for your backups, which are copies of your photos. [TBD: how much disk space the two images and their build need]
- Memory. Each container may use up to 2 GiB by default (
PHOTOBLAD_MEMORY_LIMIT). Measured with a 100 megapixel JPEG and a 48 megapixel HEIC edited at full size, the Server stayed under 512 MiB. The default leaves room for far larger pictures (PhotoBlad refuses more than 250 megapixels) and for converting long videos, which wasn’t measured. Don’t set it below 512 MiB.
The images are based on Ubuntu 24.04 and include FFmpeg, so videos get posters and playback with nothing to install. Both containers run as an unprivileged user, with a read-only root file system and no added capabilities, on a private network of their own. They publish nothing until you add a way in. The first run, from the setup code to your first scan and backup, goes as Getting started describes, except that a scan takes a folder’s path inside the containers.
Quick start
This starts PhotoBlad for this computer only, on plain HTTP: the way to try it out. In a terminal, where you keep your code:
git clone git@github.com:dustinblad/PhotoBlad.git
cd PhotoBlad/deploy
cp .env.example .env
docker compose -f compose.yaml -f compose.local.yaml up -d --build
docker compose -f compose.yaml -f compose.local.yaml logs server | grep "setup code"
compose.yaml is the base, which publishes nothing. compose.local.yaml publishes the Server on 127.0.0.1 only, at port 5180 (PHOTOBLAD_PORT in .env changes it). The build takes a few minutes the first time.
Open http://localhost:5180, enter the setup code the Server logged, and create the owner account, as Getting started describes for the first run. To choose the code yourself instead, set PHOTOBLAD_SETUP_CODE (16 or more characters) in .env before the first start, and remove the line once the owner exists. Backups from the browser work straight away. To scan photo folders you already have, mount them first: see Where your data lives.
Check the installation whenever you like:
docker compose run --rm --no-deps indexer doctor
Docker Compose reads COMPOSE_FILE=compose.yaml:compose.local.yaml from .env, so add that line and later commands need no -f options. The rest of this guide leaves them off. With Podman, keep writing them: the repository’s guides say only that Docker Compose reads that line. --build is only needed when the source has changed.
docker compose down stops and removes the containers and keeps your data. docker compose down -v deletes the volumes, and with them the database and everything PhotoBlad stores itself: use it only when that is what you want.
Choosing how to reach it
The base file publishes nothing, so you add an overlay, a second Compose file that puts something in front of the Server. Pick one, or use Caddy and Tailscale together:
| Option | What you need | What people need |
|---|---|---|
This computer only, compose.local.yaml |
Nothing. PhotoBlad is at http://localhost:5180, on 127.0.0.1 only. |
Nothing, and no one else can open it. It is for trying PhotoBlad out, or for a proxy of your own on this machine. Passkeys work only on localhost, and a phone can’t reach it. |
Caddy on a public domain, compose.caddy.yaml |
A domain name and its DNS record. TCP 80 and 443 and UDP 443 forwarded from your router, or open in a server’s firewall. An address that is really yours, not behind your provider’s shared (carrier-grade) NAT. | A browser or the PhotoBlad app, with nothing to install and nothing to trust. The sign-in page is open to the whole internet: only PhotoBlad’s accounts, its rate limits and lockouts guard it. |
Caddy on a home network, compose.caddy.yaml with Caddyfile.internal |
A name your devices find: a record in your router or Pi-hole, a hosts-file line, or the machine’s .local name. Nothing forwarded. |
Every device trusts Caddy’s root certificate once. An iPhone installs it as a profile and turns on full trust. On Android, browsers accept it, but most apps, the PhotoBlad app probably included, do not. Away from home it doesn’t work. |
Tailscale, compose.tailscale.yaml |
A Tailscale account (the free personal plan is enough to try), HTTPS certificates switched on in its admin console, and an auth key. Nothing is open to the internet or forwarded. | The Tailscale app on each phone and laptop, signed in to your tailnet and connected, whenever it should reach PhotoBlad, a background backup included. Family members join the tailnet or are shared the node. It works from anywhere. |
| Caddy and Tailscale together | Both of the rows above. | Either address works. A passkey belongs to one name, so a person who uses both registers one at each. |
Which to pick: a public domain is the easiest for people, who just open a link, and the hardest on you: DNS, the router, and a sign-in page on the internet. Choose it if you can forward ports and your address is your own. Tailscale keeps everything private and needs no router setup, at the price of the app on every phone. Choose it if you can’t forward ports, or don’t want PhotoBlad on the internet. Caddy on a home network suits a household that backs up over its own Wi-Fi, where trusting one certificate on each device is no trouble. Start with the first row to see PhotoBlad work.
Put the files you chose in COMPOSE_FILE in .env, separated by colons (semicolons on Windows), the base file first: compose.yaml:compose.caddy.yaml, or compose.yaml:compose.caddy.yaml:compose.tailscale.yaml for both. deploy/.env.example has a line for each way, commented out: uncomment the one you want.
Whichever you pick, two settings in .env say which names PhotoBlad is reached at:
PHOTOBLAD_HOSTNAMESlists the host names PhotoBlad answers to, separated by semicolons:photos.example.com;photoblad.tail1a2b3.ts.net. It becomes ASP.NET Core’sAllowedHosts. Both services are given it: the Server answers to it, anddoctor, which runs in the Indexer’s image, checks it against the origins.localhostalways works. Any other name gets400 Bad Request.PHOTOBLAD_PUBLIC_ORIGINSlists the exact https addresses where passkeys work, separated by commas, one for each name:https://photos.example.com,https://photoblad.tail1a2b3.ts.net. Empty turns passkeys off; passwords always work. A passkey belongs to one host name. The one http address that works ishttp://localhost:5180, for trying PhotoBlad out.
Mind the separators: semicolons between names, commas between origins. doctor checks that the two lists agree. It warns when an origin’s host isn’t one that PHOTOBLAD_HOSTNAMES answers to, because a browser there gets 400 Bad Request, and the warning names the host to add. It also warns when PHOTOBLAD_HOSTNAMES lists names other than this machine’s but PHOTOBLAD_PUBLIC_ORIGINS is empty, because passkeys are then off at those names. deploy/.env.example in the repository describes every setting.
Caddy: your own domain
Caddy is a web server that gets and renews HTTPS certificates by itself. compose.caddy.yaml puts it in front of the Server, so PhotoBlad is at https://your.domain, with HTTP/3, a year-long HSTS header, uploads and videos that stream straight through, and a small page instead of an error while the Server restarts. It is the only thing PhotoBlad publishes: ports 80 and 443 over TCP, and 443 over UDP for HTTP/3.
Before you start:
- A domain name, with a DNS A record for the name you’ll use, pointing at your public address. Add an AAAA record only if the machine really has a public IPv6 address and forwards it: one that doesn’t reach it can make Let’s Encrypt’s check fail.
- Ports 80 and 443 (TCP) and 443 (UDP) forwarded from your router to this machine, with nothing else of yours listening on 80 or 443 there. A rented server has no router to set up, and usually a firewall to open instead.
- No carrier-grade NAT, where a provider shares one address between several houses, and no provider that blocks port 80. If yours does, use Tailscale.
Then, in the deploy folder:
cp .env.example .env # if you have no .env yet
cat caddy/env.example >> .env # then edit the three lines below to your own
docker compose -f compose.yaml -f compose.caddy.yaml up -d --build
docker compose -f compose.yaml -f compose.caddy.yaml logs -f caddy
The three lines in .env, with your own domain in all of them:
PHOTOBLAD_DOMAIN=photos.example.com
PHOTOBLAD_HOSTNAMES=photos.example.com
PHOTOBLAD_PUBLIC_ORIGINS=https://photos.example.com
PHOTOBLAD_DOMAINis the name Caddy serves and gets a certificate for, with no scheme and no port. The overlay refuses to start without it.PHOTOBLAD_HOSTNAMESmust include the domain, or every page answers400(see Troubleshooting). Add other names after it, separated by semicolons:photos.example.com;photoblad.tail1a2b3.ts.net.PHOTOBLAD_PUBLIC_ORIGINSis where passkeys work:https://, the name, and the port when people type one. It takes a host name, never an IP address.
If .env already has one of these names, edit that line rather than adding a second.
Put COMPOSE_FILE=compose.yaml:compose.caddy.yaml in .env and later commands need no -f options. Caddy asks Let’s Encrypt for a certificate when it starts. Until it has one a browser shows a TLS error, and if that lasts more than a minute or two, the log says why. Then open your domain and create the owner with the setup code from docker compose logs server | grep "setup code". Caddy renews the certificate by itself, long before it expires, for as long as ports 80 and 443 stay reachable.
Try Let’s Encrypt’s staging service first
Let’s Encrypt limits how often a name may fail validation, and a wrong DNS record or router setting reaches that limit quickly, after which you wait, whatever you fix. Its staging service has far higher limits and issues certificates that browsers deliberately don’t trust. While you set up, add this line to .env:
PHOTOBLAD_ACME_CA=https://acme-staging-v02.api.letsencrypt.org/directory
When Caddy’s log says certificate obtained successfully and your browser warns about an untrusted issuer, everything else works. Delete the line, run docker compose up -d, and Caddy asks the real service.
The upload limit
PhotoBlad accepts a file up to PHOTOBLAD_MAX_UPLOAD_BYTES, which is 17179869184 bytes (16 GiB) unless you change it in .env. Both services apply it, so they always agree, and the Caddy overlay’s own limit, PHOTOBLAD_MAX_BODY, takes it as its default. The two limits are meant to be equal. To change the limit, change PHOTOBLAD_MAX_UPLOAD_BYTES and nothing else.
Both variables take a number of plain bytes, not a size with a unit. The app can’t read 16GiB. Caddy reads PHOTOBLAD_MAX_BODY inside an expression, where a unit such as 2GiB is an error: Caddy doesn’t start, and docker compose logs caddy says compiling CEL program … Syntax error.
Caddy applies its limit in two steps:
- A request that says how long it is is answered by Caddy at once if it says more than the limit. An upload from the app, a browser or
curlsays how long it is. Caddy replies with its own clean413,The request is larger than this server accepts., without reading any of the request and without passing it on, so PhotoBlad never sees it. - A body that doesn’t say how long it is is cut off when it reaches the limit, with the same
413. The Server has been sent that much by then, and stores nothing of it.
A file of exactly the limit goes through. The Server’s own 413 says The file is larger than this server accepts.
Set PHOTOBLAD_MAX_BODY only to give Caddy another limit than the app’s. Above the app’s limit, a file between the two reaches the Server, which refuses it and closes the connection while Caddy is still sending, so the client can hear Caddy’s 502 instead of a 413. Below it, Caddy cuts off files the app would accept.
Which address Caddy sees
The sign-in rate limit counts each client by its address, and Caddy tells the Server what it sees. Some runtimes hide the real address. Rootless Podman, rootless Docker, Docker Desktop (it runs in a virtual machine) and connections made from the machine itself can all reach Caddy from one address: with rootless Podman, in the tests, it was Caddy’s own, 172.30.0.10. Then every client shares one limit, and one person’s failed sign-ins use up everyone’s 10 a minute. Nothing breaks. With rootful Docker or rootful Podman on Linux the real address arrives. Look at one line of docker compose logs caddy for the client_ip of a request from your own device.
A home network with no public domain
Set PHOTOBLAD_CADDYFILE=Caddyfile.internal in .env, and PHOTOBLAD_DOMAIN, PHOTOBLAD_HOSTNAMES and PHOTOBLAD_PUBLIC_ORIGINS to a name your devices can find. On its first start Caddy makes a certificate authority of its own, signs a certificate for that name, and renews it.
- The name is one your devices find by themselves: a record in your router or in a home DNS server such as Pi-hole or AdGuard Home, a line in each device’s hosts file, or the machine’s own
.localname.photoblad.home.arpais the name reserved for this. Use a name and not an IP address, because passkeys need one. - The ports are 80 and 443 on this machine’s own address. Don’t forward them on your router unless you mean to be reached from outside.
- Every device must trust Caddy’s root certificate once. Until it does, its browser says the connection isn’t private, and apps refuse to connect.
Copy the root certificate out of the Caddy volume. The file is public, and the key beside it never leaves the volume:
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./photoblad-root.crt
With Podman, use podman cp with the Caddy container’s name from podman ps. Then trust it on each device:
- iPhone and iPad: get the file onto the device (AirDrop, mail or a link) and open it. Install the profile in Settings → General → VPN & Device Management, then switch on full trust for it in Settings → General → About → Certificate Trust Settings.
- Android: Settings → Security (or Security & privacy) → Encryption & credentials → Install a certificate → CA certificate. The path varies by version, and it needs a screen lock. Chrome then accepts it, but most apps do not: since Android 7, an app trusts a certificate you installed only if its developer chose to allow that. Assume the PhotoBlad app for Android will refuse this certificate, and on Android prefer a public domain or Tailscale.
- A Mac, Windows, Linux and Firefox each have steps of their own, in
deploy/caddy/README.md.
If the Caddy volume is lost, Caddy makes a new authority and every device must trust the new root again, so back the volume up (see Backups and upgrades).
The full guide, with every setting, upgrades, what Caddy does to a request and a longer troubleshooting list, is deploy/caddy/README.md in the repository. The repository is private for now.
Tailscale: your own devices
Tailscale puts PhotoBlad at a private address, https://photoblad.<your-tailnet>.ts.net, which only the devices on your own tailnet can open. A Tailscale container beside the Server ends HTTPS with a certificate that Tailscale provides and renews. Nothing is published on this machine and nothing is open to the internet: Tailscale Funnel, which would make the address public, is off. Phones and laptops that use it need the Tailscale app.
Of the overlays, this is the one that hasn’t run for real yet. The overlay follows Tailscale’s documentation and source, and was tried with a simulated sidecar, but never against a real tailnet. What has been tested says more.
In the Tailscale admin console, once
You need a Tailscale account. Its free plan is enough to try this, but its limits change, so check Tailscale’s pricing page.
- MagicDNS and HTTPS certificates. On the DNS page, make sure MagicDNS is on. Then, under HTTPS Certificates, choose Enable HTTPS. You accept that your machine names and your tailnet’s DNS name are published in a public ledger (Certificate Transparency): anyone can read
photoblad.tail1a2b3.ts.netthere, and nothing of your photos. Choose the node’s name (TS_HOSTNAME) with that in mind. - Your tailnet’s DNS name, on the same page. It looks like
tail1a2b3.ts.net, or is a name you chose, and goes inTS_TAILNET. Renaming the tailnet later changes every node’s name and breaks HTTPS links and certificates, and passkeys have to be registered again. - A tag for the node. A server should log in with a tag rather than as you: it then has no user, and its key doesn’t expire. On the Access controls page, add
"tag:photoblad": []totagOwnersin the policy file. - An auth key. On the Keys page, under Auth keys, generate one that is one-off (not reusable), not ephemeral and tagged
tag:photoblad, and pre-approved if your tailnet uses device approval. An expiry of one day is plenty, because it only has to last until the first start. Copy it when it’s shown: it can’t be shown again. - Access rules. A new tailnet lets every device reach every other, and then nothing more is needed. If you wrote rules of your own, allow your people’s devices to reach the node on port 443.
In .env
COMPOSE_FILE=compose.yaml:compose.tailscale.yaml
TS_HOSTNAME=photoblad
TS_TAILNET=tail1a2b3.ts.net
TS_AUTHKEY=tskey-auth-<your key>
PHOTOBLAD_HOSTNAMES=photoblad.tail1a2b3.ts.net
PHOTOBLAD_PUBLIC_ORIGINS=https://photoblad.tail1a2b3.ts.net
The node’s address is <TS_HOSTNAME>.<TS_TAILNET>. Both PhotoBlad settings matter, and nothing sets them for you: without the name in PHOTOBLAD_HOSTNAMES every request to it answers 400 Bad Request, and without the address in PHOTOBLAD_PUBLIC_ORIGINS the page works but offers no passkeys. The auth key is only read when the node logs in, so delete the TS_AUTHKEY line after the first start.
Start it, and check it
docker compose up -d --build
docker compose logs -f tailscale
The tailscale service waits until the Server is healthy. In its log, look for Running 'tailscale up' and then Startup complete (the wording can change between Tailscale versions). The node appears on the Machines page within seconds, tagged tag:photoblad. Then check:
docker compose exec tailscale tailscale status --json | grep -A2 CertDomains
docker compose run --rm --no-deps indexer doctor
The first command shows the node’s real full name, which must be exactly the one in .env: photoblad-1 means the name was already taken. An empty answer means HTTPS certificates aren’t enabled yet. From a device on your tailnet, open the full https:// address: nothing answers on port 80, so a bare photoblad isn’t served. The first page can take up to a minute while the certificate is issued, and later ones don’t.
Phones and family
- Each phone needs the Tailscale app, signed in to your tailnet and connected whenever it should reach PhotoBlad. An app that backs up in the background can only reach the node while the tunnel is up. That hasn’t been tried, because the phone apps aren’t released yet.
- Family members can reach the node in two ways. Invite each one to your tailnet, and each becomes a user of it, counted against your plan’s limits, with the default rules letting every user reach every device. Or share only this node, from the Machines page: the people you share it with see nothing else of yours and reach it by its full name only, and each needs a Tailscale account to accept the share. Tailscale calls sharing a beta feature.
- Tailscale decides who can open the sign-in page, and PhotoBlad’s own accounts decide who sees what. Nothing Tailscale knows about a person, such as a login or a name, signs anyone in to PhotoBlad or names anyone in it.
The full guide, with a twelve-step checklist for the first real try, every variable and what losing the node’s state means, is deploy/tailscale/README.md in the repository.
Caddy and Tailscale together
Use both overlays and PhotoBlad has a public address and a private one at once. Name both in .env:
COMPOSE_FILE=compose.yaml:compose.caddy.yaml:compose.tailscale.yaml
PHOTOBLAD_HOSTNAMES=photos.example.com;photoblad.tail1a2b3.ts.net
PHOTOBLAD_PUBLIC_ORIGINS=https://photos.example.com,https://photoblad.tail1a2b3.ts.net
They don’t collide: Caddy is at 172.30.0.10 and the Tailscale container at 172.30.0.11, both inside the range the Server trusts, and only Caddy publishes anything. A passkey belongs to one host name, so a person who uses both addresses registers one passkey at each. Sessions are per name too.
Where your data lives
Two volumes hold everything PhotoBlad writes. Both containers mount both, at the same paths, because each needs to see the files the other reads and writes.
| Volume | Mounted at | What’s in it |
|---|---|---|
photoblad-data |
/data |
The database, the secrets in keys/ (private), and the caches: thumbnails, video playback copies and edited renders. PhotoBlad makes the caches again if they’re lost. |
photoblad-storage |
/storage |
The storage locations. The first, Main, is /storage/main: the originals people back up and the owner imports, kept as plain folders such as 2026/09/alice/IMG_1234.jpg and never changed afterwards. This is the one thing you must never lose. |
Add more storage, and the photo folders you scan, in a Compose file of your own, say compose.override.yaml, added as one more -f option or at the end of COMPOSE_FILE:
services:
server:
volumes:
- pool2:/storage/pool2
- /path/to/photos:/library/family:ro # add ,z on SELinux hosts
indexer:
volumes:
- pool2:/storage/pool2
- /path/to/photos:/library/family:ro
volumes:
pool2:
- More storage locations. Give each its own volume or drive, mounted at the same path in both containers, under
/storage. Then add the path in Settings → Storage, or configure it in the same file with two more lines in theenvironmentof both services (PhotoBlad__Storage__Locations__1__Name: Pool 2andPhotoBlad__Storage__Locations__1__Path: /storage/pool2). A location configured that way is brought back by itself after a lost database: After losing the database says how. - A new volume can belong to root, so hand it to PhotoBlad’s account once:
docker run --rm -v photoblad_pool2:/v alpine chown 10001:10001 /v(docker volume lsshows the volume’s real name). - A drive you mount from the host, such as
/mnt/disk2:/storage/disk2, must belong to uid 10001 on the host:chown 10001:10001 /mnt/disk2, or with rootless Podmanpodman unshare chown 10001:10001 /mnt/disk2. If the drive isn’t mounted when a container starts, its folder is empty and has no marker file, so the location stays offline and nothing is ever written into it. - Photo folders you scan, which PhotoBlad only reads. Mount each read-only, at the same path in both containers, and enter that path when you scan. With
:rothe operating system itself keeps them untouched, even if there were a bug, anddoctorwarns about a folder mounted read-write.
Other settings
These go in .env too. deploy/.env.example lists every setting, with its default.
| Variable | Default | What it does |
|---|---|---|
PHOTOBLAD_MAX_UPLOAD_BYTES |
17179869184 |
The largest file an upload may be, in plain bytes (16 GiB), for both services. A number, not 16GiB. The Caddy overlay’s limit follows it: see The upload limit. |
PHOTOBLAD_PORT |
5180 |
The port compose.local.yaml publishes, on 127.0.0.1. |
PHOTOBLAD_MEMORY_LIMIT |
2g |
The memory each container may use. |
PHOTOBLAD_SETUP_CODE |
empty | Chooses the first-run setup code, 16 or more characters. Remove the line once the owner exists. |
PHOTOBLAD_SERVER_IMAGE, PHOTOBLAD_INDEXER_IMAGE |
photoblad-server, photoblad-indexer |
The names the two images are built as and used under. |
No home folder. The images’ account has none, so PhotoBlad has no default for PhotoBlad__DataDirectory, PhotoBlad__Uploads__Directory or PhotoBlad__Versions__Directory there. Elsewhere they default to ~/.photoblad, ~/PhotoBlad/Uploads and ~/PhotoBlad/Versions, and a path written ~/… can’t be expanded without a home folder. The images and compose.yaml set all three, so nothing changes for them. If you start an image by hand, or take one of the three out, the service, or the users, db backup or storage rebuild command, says which setting to make and stops with exit status 2, instead of keeping data in whatever folder it started in. doctor fails the setting by name.
Everyday commands
Each command runs in a one-off container made from the same image and settings as the running services. --no-deps stops it starting anything that isn’t running.
# check the installation
docker compose run --rm --no-deps indexer doctor
# list the accounts, and give one a new password
docker compose run --rm --no-deps server users list
docker compose run --rm --no-deps server users reset-password alice
# copy the database, safely, while PhotoBlad runs
docker compose run --rm --no-deps server db backup /storage/backups/photoblad.db
# after losing the database, with no copy of it (the steps are under Backups and upgrades)
docker compose stop indexer
docker compose run --rm --no-deps indexer storage rebuild
docker compose start indexer
doctorafter the first start, after changing.envor an overlay, and whenever something is wrong. It prints PASS, WARN or FAIL for each check, a one-line fix for each WARN and FAIL, and exits with 1 when anything failed. What it checks is below.users reset-passwordwhen the owner, or anyone, can’t sign in. It asks twice for a new password, clears any lockout and signs the account out everywhere. A member who forgets a password gets a new link from the owner in Settings → Members, as usual.db backupbefore an upgrade, and on a schedule: see Backups and upgrades.storage rebuildonly after the database was lost and you have no copy. It reads every storage location and records its backups and versions again, with the ids, owners and devices each location’s manifest remembers. Run it with the Indexer stopped, after you have started PhotoBlad and created the accounts again, or before: After losing the database has the steps. It changes no file.
Run from source, doctor and db backup are dotnet run --project src/PhotoBlad.Indexer -- doctor and dotnet run --project src/PhotoBlad.Server -- db backup <file>.
What doctor checks
It reads the same settings the services do, needs no network, prints no secret and writes nothing else. It checks:
- FFmpeg, and the encoders and demuxers PhotoBlad needs.
- Photo decoding, by decoding real JPEG, PNG, WebP and HEIC images.
- The data folder: writable, free space,
keys/and the internal key private, the database readable and its migrations current. - Every storage location: online, free space against its reserve, its file system, and whether a stored file could be overwritten by a later one. It tries that with two small files in the location’s
.incoming, which it removes. - Library folders, with a warning for one mounted read-write.
- The proxy settings, including that the names and the origins agree: see Choosing how to reach it.
- How the Server finds the Indexer.
A warning about AllowedHosts on a first run only means PHOTOBLAD_HOSTNAMES isn’t set, which is right for a computer used only locally.
Backups and upgrades
What to back up
Most important first:
- The storage volume, or every storage location’s folder: the originals and their versions. Losing it loses your photos for good. Copy it with any tool, while PhotoBlad runs or stopped, because files are written once and never changed. Copy hidden files too: a location’s marker file and manifest are what let PhotoBlad recover it.
- The database, with
db backup. It holds the accounts, edits, favourites, sharing settings and the record of every file. There is nosqlite3in the image, so the command makes the copy itself, checks it with SQLite’s integrity check and prints its size. It refuses to replace a file, so give each copy a new name. Written to/storage/backups, as above, the copy sits in the storage volume, so take it somewhere else:docker compose cp server:/storage/backups/photoblad.db ./. To restore, stop both services, put the copy in the data volume asphotoblad.db, owned by uid 10001 and with the old-waland-shmfiles gone, and start them;deploy/README.mdhas the exact command. - The
keysfolder in the data volume: the key the two services share, and the key ring that keeps people signed in. Losing it only signs everyone out. Keep it as private as the database. - The Caddy and Tailscale volumes, if you use them.
caddy_dataholds the certificates and their private keys, and withCaddyfile.internalthe root key of Caddy’s authority, which every device trusts.tailscale_stateholds the node’s private key and certificate. Losing either isn’t a disaster, but you start again: new certificates, or a new root for every device to trust, or a new node that needs a new auth key (delete the old machine on the Machines page first, or the new one becomesphotoblad-1and PhotoBlad doesn’t answer to that name).
The thumbnails, playback copies and edited renders in cache/ are disposable. The two proxy volumes can be copied like this, as their own guides have it. keys/ is in the data volume and is copied out of the Server with docker compose cp. Keep every copy as private as the database:
docker compose cp server:/data/keys ./photoblad-keys
chmod -R go-rwx ./photoblad-keys # the key ring and the internal key: keep them as private as the database
docker run --rm -v photoblad_caddy_data:/data:ro -v "$PWD":/backup alpine tar czf /backup/caddy_data.tar.gz -C /data .
chmod 600 caddy_data.tar.gz # it holds private keys: keep it as private as the database
docker run --rm -v photoblad_tailscale_state:/state:ro -v "$PWD":/out alpine tar czf /out/tailscale-state.tgz -C /state . && chmod 600 tailscale-state.tgz
After losing the database
With a copy of the database, restore it as above. With none, your files are all still in the storage volume, and only the accounts, edits and favourites are gone. Start the stack as always, then:
- Start the services.
docker compose up -d. Each storage location the Compose files configure is adopted back from its marker file:compose.yamlconfigures the first, Main at/storage/main, and you may configure more. The Indexer finds, in the folder, the marker of a location the new, empty database doesn’t know, and registers it again under the marker’s id, name and date. It logsAdopted the storage location Main at /storage/main from its marker, writes nothing in the folder, and comes up healthy instead of stopping at every start. - Create the accounts again. Open PhotoBlad’s
/setuppage, enter the setup code the Server logs (docker compose logs server | grep "setup code"), and create the owner. Then add the other accounts in Settings → Members, with the same user names as before: each person’s folders, such as2026/09/alice, are matched to accounts by name. - Record the files.
docker compose stop indexer, thendocker compose run --rm --no-deps indexer storage rebuild, thendocker compose start indexer. It records every backup and version in the locations again, with the ids, owners and devices each location’s manifest remembers, makes each account an owner of its backups, and changes no file.
A location you added in Settings → Storage isn’t configured in Compose, so it isn’t adopted at the start. Bring it back from its marker with --root, one for each: storage rebuild --root /storage/pool2. You can also rebuild first: with everything stopped, storage rebuild --root /storage/main registers the location from its marker, and the Indexer then finds it registered.
The Indexer still refuses, naming the folder, when a marker belongs to a location that is registered at another folder (the folder was copied, or the location moved) and when a marker file can’t be read (PhotoBlad never writes a marker over another). It then restarts every second until you mend it: Troubleshooting says how.
Upgrading
Back up the database first, because the first start of a new version migrates it:
docker compose run --rm --no-deps server db backup /storage/backups/before-upgrade.db
git pull
docker compose up -d --build
The log of that first start may carry an EF Core warning that PRAGMA foreign_keys = 0 can’t run in a transaction. It’s expected when a migration rebuilds a table, and a good reason for the copy: a migration that stopped half way would leave the database half changed. doctor says how many steps behind a database is, and refuses one that a newer PhotoBlad wrote. To go back, stop everything, restore the copy and run the older version.
Caddy’s and Tailscale’s images are pinned to an exact version on purpose, because a new release can change how their settings are read. Upgrading PhotoBlad leaves them alone, and their guides say how to move to another version.
Security notes
- Nothing but the proxy is published. The base file publishes nothing. Caddy publishes ports 80 and 443, and the Tailscale container publishes none and is reached through your tailnet.
compose.local.yamlpublishes the Server on127.0.0.1only: keep it there, because whoever can open that port can choose the client address a rate limit counts, and whether a request looks like HTTPS. - The Indexer is never reachable from outside. It publishes no port, only the Server can reach it, and it answers only requests that the Server signs with a key they share.
- The Server trusts forwarded headers only from the pinned network. The private network has a fixed address range,
172.30.0.0/24, and only requests from inside it may say which client they came from (X-Forwarded-For) and whether they were HTTPS (X-Forwarded-Proto). From anywhere else those headers are ignored, and the Server logs a warning. Keep other containers off that network, because everything on it is trusted to say who a client is. - Sign-in attempts are rate limited for each client, counted by address, at 10 attempts a minute. A globally routable IPv6 address, one in
2000::/3, is counted with the rest of its /64, so hopping between a device’s addresses doesn’t reset the limit. Every other address counts alone, a Tailscale device’s included, so devices that share their first 64 bits still each have a budget. If several people signing in at once ever get429 Too Many Requests, waiting a minute clears it. Where Caddy can’t see the real address, everyone shares one limit: see Which address Caddy sees. - PhotoBlad answers only at the names you list in
PHOTOBLAD_HOSTNAMES, and atlocalhost. - HSTS comes from Caddy. Every answer carries
Strict-Transport-Security: max-age=31536000, so browsers use HTTPS for your domain for a year. It has noincludeSubDomains, which would commit every other name under the domain, and nopreload. Tailscale’s route sends no such header, but nothing answers there on plain HTTP. - Passkeys work per host name, at the exact https addresses in
PHOTOBLAD_PUBLIC_ORIGINS. Register one for each name you use. Sessions are per name too. - The Server never reads Tailscale’s identity headers. Tailscale tells a server who is connecting with
Tailscale-User-*headers. Nothing in PhotoBlad reads them, for any purpose, and a test fails if any of its source names one. PhotoBlad’s own accounts are the only identity. - Funnel is off. The Tailscale overlay never makes PhotoBlad reachable from the public internet, and a test fails if its serve file ever turns Funnel on. For a public address, use Caddy.
- The containers run with little. The Server and the Indexer run as an unprivileged user with a read-only root file system, every capability dropped and
no-new-privileges. Caddy keeps one capability, to listen on ports 80 and 443, and the Tailscale container none. - Caddy’s log leaves out what’s private. It keeps one line per request, without the query string (tags and searches are in it) and with the
Cookie,Set-CookieandAuthorizationheaders replaced byREDACTED. - Secrets stay private.
.envcan hold a setup code and a Tailscale auth key, so keep it out of Git; the repository ignores it. Thekeysfolder and the Caddy and Tailscale volumes hold private keys, so keep their backups as private as the database.
Troubleshooting
docker compose logs -f server indexer shows what the services say, and docker compose ps shows whether each is healthy.
| You see | It means, and what to do |
|---|---|
| The browser shows 400 Bad Request and Invalid Hostname | The name you opened isn’t in PHOTOBLAD_HOSTNAMES: the most common mistake. Add it, separated from any others by a semicolon, and run docker compose up -d. With Tailscale the name must be exactly the node’s real name: photoblad-1 means the name was taken. doctor warns when an origin in PHOTOBLAD_PUBLIC_ORIGINS has a host that PHOTOBLAD_HOSTNAMES doesn’t answer to, and names the host to add. |
| The page loads but there is no passkey button | PHOTOBLAD_PUBLIC_ORIGINS doesn’t list exactly https:// and the name you opened. Passwords work either way. doctor warns when PHOTOBLAD_HOSTNAMES lists names other than this machine’s and PHOTOBLAD_PUBLIC_ORIGINS is empty. |
ERR_SSL_PROTOCOL_ERROR in the browser |
Caddy has no certificate for that name: the first one hasn’t been issued yet or has failed, and Caddy’s log says which, or you opened the machine by its IP address or by another name. |
Caddy’s log says DNS problem: NXDOMAIN |
The A record, or the AAAA record, is missing or hasn’t spread yet. |
Caddy’s log says Timeout during connect or Connection refused |
Ports 80 and 443 don’t reach Caddy: the router’s forwarding, a firewall, your provider, or an AAAA record with no IPv6 behind it. Test from a phone on mobile data. |
Caddy’s log says rateLimited |
Let’s Encrypt’s limits. Wait as the message says, and use the staging service while you fix the cause. |
| Your connection isn’t private | With Caddyfile.internal: the device doesn’t trust Caddy’s root certificate yet. With a public domain: PHOTOBLAD_ACME_CA still names the staging service. |
| Your domain works on mobile data but not on your own Wi-Fi | The router can’t send a request out and back in again, which is called NAT loopback or hairpin NAT. Turn it on in the router, or make the name resolve to the server’s home address inside the house, with a DNS record in the router or Pi-hole. |
The Server logs Ignored X-Forwarded-For and X-Forwarded-Proto from …, which isn't a trusted proxy |
A request reached the Server from outside 172.30.0.0/24. Either a proxy of your own is in front of it, so add its address to PhotoBlad__Proxy__KnownNetworks, or the subnet was changed in only one of its two places, networks in compose.yaml and that setting. |
Everyone shares one rate limit, and the log’s client_ip is 172.30.0.10 or a gateway address |
The runtime hides client addresses from Caddy: see Which address Caddy sees. |
| It works on a laptop but not on a phone | With Tailscale: the phone’s Tailscale is off or signed in to another tailnet, or the node was shared with it and it uses the short name (a shared node answers to its full name only), or an access rule doesn’t allow it. With Caddy on a home network: the phone doesn’t trust Caddy’s root certificate. |
| PhotoBlad isn’t answering, a 502 page from Caddy | Caddy is fine and the Server isn’t answering: it’s starting, which takes up to a minute after an upgrade, or it stopped. Look at docker compose ps and docker compose logs server. |
address already in use or port is already allocated for port 80 or 443 |
Another program holds the port, such as Apache, nginx or another proxy. Stop it, or move PhotoBlad with PHOTOBLAD_HTTP_PORT and PHOTOBLAD_HTTPS_PORT; the Caddy guide says what that needs. |
Rootless Podman: permission denied on port 80 |
Ports below 1024 are privileged. Run sudo sysctl net.ipv4.ip_unprivileged_port_start=80 on the host, or publish higher ports and forward to them. |
required variable PHOTOBLAD_DOMAIN is missing a value |
PHOTOBLAD_DOMAIN isn’t in .env. |
Caddy doesn’t start, and docker compose logs caddy says compiling CEL program … Syntax error |
PHOTOBLAD_MAX_BODY has a size with a unit, such as 2GiB. Write plain bytes, such as 17179869184. |
413 with The request is larger than this server accepts. |
Caddy’s limit, PHOTOBLAD_MAX_BODY, which is PHOTOBLAD_MAX_UPLOAD_BYTES unless you set it. The Server’s own 413 says The file is larger than this server accepts. |
An upload over the limit ends with a 502 and PhotoBlad isn’t answering, or with a connection error, and not the 413 |
PHOTOBLAD_MAX_BODY is set above the app’s limit. Remove it from .env, or set it to the same number as PHOTOBLAD_MAX_UPLOAD_BYTES, and Caddy answers with its own clean 413. Nothing is stored either way. |
Access to the path '/data/...' is denied |
The volume or folder isn’t owned by uid 10001. doctor shows it under the data folder. |
| A service stops at the start and names a setting, with exit status 2 | A setting is wrong or missing. With no home folder, which is how the images’ account is, it is one of PhotoBlad__DataDirectory, PhotoBlad__Uploads__Directory and PhotoBlad__Versions__Directory. Compose sets all three, so this happens if you started an image by hand or took one out. |
The log says Adopted the storage location … from its marker |
The database was lost or replaced and the volume wasn’t, so the location came back from its marker. Not a problem. Follow After losing the database to record its files. |
The Indexer restarts every second, and the log says a configured folder holds the marker of the storage location "X", which is registered at … |
The folder is a copy of X’s folder, or X was moved there. Point X at the folder in Settings → Storage (Find) and remove its configuration lines (PhotoBlad__Storage__Locations__…), or delete the copied .photoblad-location in the copy. |
The Indexer restarts every second, and the log says a folder holds a .photoblad-location marker that can't be read |
The marker is damaged, or not readable by uid 10001. Make it readable (doctor names the file), or put back an undamaged copy. |
The tailscale container restarts every minute, and its log says failed to auth tailscale |
The auth key was used already, has expired or been revoked, or was mistyped, or its tag isn’t in tagOwners. Make a new key. |
The tailscale log says it cannot issue certificates, or the CertDomains check shows nothing |
HTTPS certificates aren’t enabled for your tailnet. Enable them on the DNS page, then run docker compose restart tailscale. |
The tailscale container exits at the start with a “read-only file system” error |
The overlay’s read-only root has not been tried with the real Tailscale daemon. Remove the read_only: true and tmpfs lines from compose.tailscale.yaml, and report it. |
What has been tested
PhotoBlad’s containers were built and run for real with Podman 6.1 and podman-compose 1.6 on an Apple-silicon Mac, a linux/arm64 machine.
- Tested for real, with Podman. The images build and
doctorpasses in them. The stack starts healthy. A scan of a read-only library, uploads (a photo, videos, a HEIC), thumbnails, posters and playback copies, full-size edited exports, a hard crash of the Indexer in the middle of an upload, stopping and starting,users,db backupand the lost-database recovery all work. - The last pass, on the final images. The lost-database recovery with the storage locations configured in Compose, with no crash loop: the Indexer adopts the location from its marker. A container with no home folder and none of the data settings, where the Server and the Indexer end with a message that names the setting. Files at and over the upload limit through Caddy: the file at the limit was stored, every file over it was refused with Caddy’s own
413before the Server was sent a byte, and a body with no declared length was cut off at the limit. - Caddy, tested for real. The pinned Caddy image ran under Podman. The
caddyprogram installed on that Mac ran in front of the real Server and Indexer, with Caddy’s own certificate authority: about a hundred checks of HTTPS and the redirect, headers, cookies, passkeys, a 2 GB upload streaming at flat memory, byte ranges, HTTP/3 and the friendly page when the Server is down. The main checks passed again through the Compose overlay in Podman. - Tailscale, as far as it goes without Tailscale. The overlay’s files and variable names are checked by tests, and the Server and the Indexer were driven through a stand-in container at the sidecar’s address that sends what
tailscale servesends. The Tailscale software itself was never started. - Both overlays together, with Caddy and the stand-in: both names accepted, a passkey origin for each, one rate limit bucket for each client and a clean
doctor.
What still needs the real thing
What is left needs what that Mac doesn’t have. This is the one list of it, merged from the Caddy and Tailscale guides, which say how to check their parts.
A real Docker (rootful, rootless and Docker Desktop):
- Both architectures:
docker compose build,docker buildx build --platform linux/amd64,linux/arm64, and running the amd64 image natively. - Named volumes and bind mounts under Docker’s own ownership rules, and SELinux
:zlabels. docker compose cptaking the database copy and thekeys/folder out of the containers.doctoron real storage: the no-replace rename on ext4, xfs, btrfs or ZFS, and what it says of NFS, SMB or exFAT drives.- Start order with
depends_onandservice_healthy, the restart policy after a crash,stop_grace_period, the fixed addresses,cap_dropand the read-only root, as Docker does them. All were seen working under Podman. - Memory under the limit on your own hardware, with your largest photos and videos.
- Which client addresses your runtime shows Caddy. Rootless runtimes and Docker Desktop can hide them, so that every client counts as one for the sign-in rate limit: look at one line of
docker compose logs caddy.
A real tailnet (the Tailscale software was never started here, because there is no account):
- The node logs in with your auth key, appears in the admin console tagged and with its key expiry off, and comes back as the same device after a restart.
- Its HTTPS certificate is issued, and how long that takes, and the name the node gets is the one in
.env(photoblad-1if the name was taken). - That
tailscale servetakeshttp://server:8080as its target, and that the daemon runs to the end with a read-only root, no capabilities and two tmpfs folders. Its start was checked, not its whole run. - The headers the Server really receives from it. They come from Tailscale’s source at the pinned version, and the Server’s handling of them from tests that send them, not from a capture.
- Which IPv6 addresses a tailnet assigns and which one a browser uses (each is a client of its own for the rate limit), sharing the node with family, and Funnel staying off.
A real certificate from Let’s Encrypt:
- The public
Caddyfileparses and validates with its real variables, but no exchange with Let’s Encrypt was made. Use the staging service first, then the real one, and see a certificate renew.
A real router:
- Forwarding of TCP 80 and 443 and UDP 443, hairpin NAT (or split DNS) to reach your domain from your own Wi-Fi, IPv6, a provider’s shared (carrier-grade) NAT, and HTTP/3 from a browser over the internet.
A real phone:
- Trusting
Caddyfile.internal’s root certificate (an iPhone’s profile and full trust, or Android’s user store, which most apps ignore), and whether the PhotoBlad app accepts it. - What a browser and the phone app show for a file over the upload limit. Caddy’s
413was read bycurland a small client of our own, not by Safari, Chrome or the app’s own HTTP stack over a real network. - With Tailscale: a phone with the Tailscale app reaching the node, and losing it when Tailscale is turned off; iOS’s VPN On Demand and Android’s Always-on VPN keeping the tunnel up for a background backup (the phone apps aren’t released yet); and a passkey at each name.
deploy/README.md ends with the same list, and the Caddy and Tailscale guides say how to check their parts.