Can I self-host IDrive?
YES · ONE EVENING— setup effort 2 of 4YES — it's called Duplicati. It takes one prompt, a 1024 MB VPS, and about 90 minutes. That is $9.99 a month you stop paying IDrive — $119.88 a year on the IDrive Personal · 5 TB plan.
Why people pay for IDrive
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.
IDrive sells one price that covers both halves of a backup: the agent that watches your folders and the terabytes it uploads them to. You install it on every computer you own, it keeps versions, it syncs between machines, and when the laptop is gone there is a web page you log into and a courier service that will ship you a drive. The thing nobody self-hosting can match on the invoice is that bundling, because the moment you separate the client from the storage you start paying two people instead of one.
| Plan | List price | What it buys |
|---|---|---|
| Basic | free | 10 GB free, no credit card. The pricing page keeps it on every billing tab, and it is also the account you need if you only want the add-on services. |
| IDrive Mini · 500 GB | $0.83/mo | Only offered on the yearly tab, one user, with 100 GB at $0.25 a month beside it. No strikethrough price is shown on either, so these read as standard rather than first-year rates. |
| IDrive Personal · 5 TBthe plan this page prices against | $9.99/mo | Billed annually, one user and multiple computers. The page prints it as $9.99 struck through and $6.99 in force, and the small print says the discount applies to the first year only. Monthly billing without the annual commitment is $11.99. The same ladder runs 10 TB at $14.99, 20 TB at $24.99, 50 TB at $49.99 and 100 TB at $99.99, all in standard money. |
| IDrive Team · 5 TB | $9.99/mo | Five computers and five users at the same headline figure as Personal, discounted to $6.99 for the first year, and it scales in lockstep: 10 computers and 10 TB at $19.99, 25 at $49.99, on up to 500 computers and 500 TB at $999.99 a month. |
| IDrive Business · 500 GB | $19.99/mo | Unlimited users, and servers, Exchange, SQL and NAS devices are in scope rather than extra. $13.99 for the first year. The ladder is steep above that: 1.25 TB at $49.99, 5 TB at $149.99, 100 TB at $2,316.66 a month. |
Vendor list prices in USD, read from the pricing page on 2026-08-12 · confidence: high
Replaced by Duplicati
One project, named before the prompt, so you know what you are about to install.
A backup client with a web interface: schedules, versions, and AES-256 applied on your own machine before anything is uploaded to storage you chose.
The closest answer to the shape IDrive sells, as long as you follow the money. Duplicati is a backup client with a web UI: schedules, versioned incremental backups, AES-256 encryption applied on your own machine before anything is uploaded, and a backend list at the pinned tag that runs from S3-compatible object storage, Backblaze B2, Azure, Google, OneDrive and Dropbox through to SFTP, FTP, WebDAV, SMB and rclone. IDrive's own e2 object storage is on that list too, which is one honest way to keep the vendor and change the product. What Duplicati does not include is the storage, so a single invoice becomes a subscription you stop paying and a per-gigabyte bill you start paying, or a second machine you run yourself. Below a few hundred gigabytes that usually comes out ahead of a 5 TB plan you are not filling; at several terabytes the arithmetic gets closer, and you should do it before you switch rather than after. The encryption passphrase is yours alone, which is the good news and the bad news.
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.
- Duplicatiour pickONE EVENING— setup effort 2 of 4
A backup client with a web interface: schedules, versions, and AES-256 applied on your own machine before anything is uploaded to storage you chose.
The closest answer to the shape IDrive sells, as long as you follow the money. Duplicati is a backup client with a web UI: schedules, versioned incremental backups, AES-256 encryption applied on your own machine before anything is uploaded, and a backend list at the pinned tag that runs from S3-compatible object storage, Backblaze B2, Azure, Google, OneDrive and Dropbox through to SFTP, FTP, WebDAV, SMB and rclone. IDrive's own e2 object storage is on that list too, which is one honest way to keep the vendor and change the product. What Duplicati does not include is the storage, so a single invoice becomes a subscription you stop paying and a per-gigabyte bill you start paying, or a second machine you run yourself. Below a few hundred gigabytes that usually comes out ahead of a 5 TB plan you are not filling; at several terabytes the arithmetic gets closer, and you should do it before you switch rather than after. The encryption passphrase is yours alone, which is the good news and the bad news.
- BackrestONE COMMAND— setup effort 1 of 4
A web UI and a scheduler over restic, so the backups you keep meaning to set up have somewhere to be configured and something to watch them.
The same job with a different engine, and it keeps the top spot on its own page as the restic answer. Backrest is a web UI and a scheduler over restic rather than a backup program in its own right, which means restic's deduplication and its repository format, plus restic's command line underneath when something needs unpicking. Duplicati ranks first here because the IDrive shape is schedule-and-forget encrypted backups with a browser to configure them in, and Duplicati is that with a wider backend list and no second tool to learn. If you already trust restic, or you want the option of restoring from a laptop with no UI at all, the ranking flips.
The swap
You'd run
Duplicati
ONE EVENING · ~90 min to running · 1024 MB RAM
IDrive IDrive Personal · 5 TB · vendor list price · checked 2026-08-12 · source
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 5 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
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same Duplicati: 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.
Where it runs
315 lines · 14,994 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Duplicati 2.3.0.4 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.
Say two things to the user first. Duplicati is a backup client, not a place to put backups: it
encrypts files on the machine it runs on and uploads them to a destination the user supplies, so
what stops is a subscription and what starts is a storage bill. And the machine here is this
server, meaning /srv and what this catalogue keeps in it, not a laptop.
Duplicati needs 1024 MB of RAM available and 5 GB free on /srv. The image publishes amd64, arm64
and arm/v7. Measure all four.
```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 1024 MB or free disk is under 5 GB, print both numbers 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. The 5 GB covers the image, the temporary volumes
built while uploading, and a per-job database that grows with the file count.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/duplicati /srv/duplicati/backups
sudo install -d -m 700 /srv/duplicati/data /srv/duplicati/restore
ls -la /srv/duplicati
```
Assert: `ls -la` shows `backups` owned by the login user, and `data` and `restore` at mode `700`
owned by root. The container runs as root, because no UID and GID pair is set for it and the
files it reads under /srv were written by other services. `data` holds Duplicati's own settings
database, a different thing from the backups it makes.
## 3. Secrets
Two secrets, generated here on the server. Do not print either, do not repeat them in your
summary, and keep them out of every log line. Hex, because a human types one of them into a
form.
The security decision is the first line. Duplicati always has a web password: set none and it
generates a random one at first start, then writes a one-time sign-in link into the container
log, a credential sitting in `docker compose logs` on a public hostname. Setting the password
first replaces that with a value the user owns. `SETTINGS_ENCRYPTION_KEY` is the one variable
here with no `DUPLICATI__` prefix; it encrypts the credential fields in the settings database,
where the destination's access keys are about to live. The third line is not optional either: the
allowed-hostname list ships holding localhost, 127.0.0.1 and bare IPs, so a Duplicati behind a
real hostname refuses its own front end until that name is on it.
```bash
umask 077
cat > /srv/duplicati/.env <<EOF
DUPLICATI__WEBSERVICE_PASSWORD=$(openssl rand -hex 24)
SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)
DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES=<DOMAIN>
EOF
chmod 600 /srv/duplicati/.env
umask 022
ls -l /srv/duplicati/.env
sudo grep DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES /srv/duplicati/.env
```
Replace `<DOMAIN>` in that block with the real hostname before running it. Assert: the file is at
mode `-rw-------`, and the last command prints the real hostname, bare, with no scheme, no port
and no angle brackets. If it still shows the placeholder, stop and fix it: the API answers 403 to
everything through Caddy until it is right. Tell the user the web password is readable with
`sudo grep DUPLICATI__WEBSERVICE_PASSWORD /srv/duplicati/.env` and that both values go in their
password manager today.
## 4. compose.yml
```bash
cat > /srv/duplicati/compose.yml <<'EOF'
# Duplicati · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker image readme . https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# image build ......... https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/Dockerfile
#
# One service. Duplicati is the backup client, not the storage behind it: it
# encrypts files here and uploads them to a destination you pay for
# separately. It runs as root, since no UID and GID pair is set and the files
# it reads under /srv belong to other services. .env carries the web password,
# the settings-database encryption key and the hostname the API answers for,
# because Duplicati refuses a request whose Host it does not know.
#
# Tag and digest read from docker.io on 2026-08-12; the manifest list covers
# amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
duplicati:
image: duplicati/duplicati:2.3.0.4-stable@sha256:01f8cb81ad7d548b7ceec61d696bb5d27d8057fee0ddee37c2b8a0ff1f1729f7
container_name: duplicati
restart: unless-stopped
env_file: /srv/duplicati/.env
environment:
# The container's clock zone. Schedules fire against it.
TZ: UTC
# Usage reporting ships on; this is upstream's own opt-out variable.
DO_NOT_TRACK: "1"
volumes:
# Duplicati-server.sqlite, the per-job databases, the JWT signing keys.
- /srv/duplicati/data:/data
# Everything this catalogue keeps, read only. Restores go to /restore.
- /srv:/source:ro
- /srv/duplicati/restore:/restore
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8188.
- "127.0.0.1:8188:8200"
healthcheck:
# /health needs no token and no allowed hostname; curl is in the image.
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8200/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
EOF
cd /srv/duplicati && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one port, no database container: the settings and
the file indexes are SQLite files in /srv/duplicati/data.
## 5. Caddy and TLS
Write the site block to its own file, then append it with the hostname substituted. Copy the
live Caddyfile first: a syntax error takes down every other site here.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-duplicati
cat > /srv/duplicati/Caddyfile <<'EOF'
# Duplicati · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That name is
# also DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES in .env: Duplicati answers 403
# to an API request whose Host is not on its list, and that list ships
# holding localhost and nothing else.
<DOMAIN> {
encode zstd gzip
# Duplicati sets no transport headers of its own. HSTS is on because
# every request here carries the token that reads this server.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8188 is the loopback port compose publishes here. It is not open in
# the firewall. The progress feed on /notifications is a WebSocket,
# which reverse_proxy upgrades with no extra directive.
reverse_proxy 127.0.0.1:8188
}
EOF
DUP_HOST=$(sudo grep DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES /srv/duplicati/.env | cut -d= -f2-)
echo "site block will be appended for $DUP_HOST"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|$DUP_HOST|g" /srv/duplicati/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: the echo prints the real hostname, `caddy validate` exits 0, the reload exits 0. The
substitution reads the hostname out of .env rather than asking again, so the site block and the
allowed-hostnames value cannot disagree. If validate fails, restore
/etc/caddy/Caddyfile.before-duplicati, reload, and say what it objected to. Caddy gets the
certificate on the first request and renews it alone.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so on a Prompt Zero box they change nothing.
```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. 8188 stays closed because compose binds it to 127.0.0.1. Assert: `ufw status verbose`
prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for 8188.
## 7. Start and verify
```bash
cd /srv/duplicati
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/backups
printf '{"Password":"%s","RememberMe":false}' "$(sudo grep DUPLICATI__WEBSERVICE_PASSWORD /srv/duplicati/.env | cut -d= -f2-)" | curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/v1/auth/login -H 'Content-Type: application/json' --data-binary @-
```
Assert all four, and print what you received for each. The loop ends printing `200`. The second
prints `Healthy`. The third prints `401`, the security assert here: the API refuses a call
carrying no token, so nobody who finds the hostname can read or edit a backup job. The fourth
prints `200`, proving both that the generated password is the one the server holds and that the
allowed-hostnames value took effect, since that route rejects an unknown Host with `403` first.
The password reaches curl on standard input, never a command line.
If any of the four misses, stop, run `docker compose logs --tail 40 duplicati`, and name the
likely earlier step. A `403` on the fourth is step 3 or 5, the hostname disagreeing. A `401`
there means .env holds a password the container did not start with, which happens if it ran
before .env existed: `docker compose down`, then `up -d`. A running container is not success.
STOP: tell the user to read their password with
`sudo grep DUPLICATI__WEBSERVICE_PASSWORD /srv/duplicati/.env`, put it in their password manager,
open https://<DOMAIN>, and sign in. Do not continue until they confirm. The first screen is one
`Password` box: Duplicati has no usernames and no second account.
## 8. First backup and restore
Two different backups live in this step. The archive below backs up Duplicati itself, the
settings database that knows what to copy and where to send it. The user's own first backup job
is the second half.
```bash
cd /srv/duplicati
docker compose stop
sudo tar -czf /srv/duplicati/backups/duplicati-config-$(date +%F).tar.gz -C /srv/duplicati data compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/duplicati/backups/
```
Assert: the archive exists and is non-empty. Print its size. The container is stopped on purpose,
because a SQLite database copied mid-write is not a backup. The archive holds .env, so it holds
the key protecting everything else in it.
A backup on the same disk is not a backup. Run this on the user's machine:
```bash
mkdir -p ~/backups/duplicati
scp vps:/srv/duplicati/backups/*.tar.gz ~/backups/duplicati/
```
STOP: tell the user to open https://<DOMAIN>, choose `Add backup`, set a passphrase, pick a
destination, add `/source` as the source folder with `/source/duplicati` excluded, run the job
once, then restore one file from it into `/restore`. Do not continue until they confirm. Two
things to tell them while they do it. The passphrase encrypts every file before it leaves this
box, nothing here can recover it, and losing it makes the destination unreadable to them as much
as to anyone else. And the source box wants paths inside the container, where /srv is `/source`,
while the destination is a bill from somebody: a bucket, an SFTP account, or a disk in a friend's
house.
```bash
sudo ls -lR /srv/duplicati/restore
```
Assert: the restored file is there and non-empty. Print the listing. A backup nobody has restored
is a hope, and that turns it into a fact. To restore Duplicati itself: `docker compose down`,
`sudo rm -rf /srv/duplicati/data`, recreate it as in step 2, untar the archive back into
/srv/duplicati so .env is in place before anything starts, restore the Caddy block if it was
lost, then `docker compose up -d` and re-run step 7's checks.
## 9. Updating later
Upstream publishes three channels and this install pins stable, the slowest; beta and canary
carry higher version numbers on the same day and are where changes are tried out. Backups are the
wrong place to be early. Stable releases appear at
https://github.com/duplicati/duplicati/releases with a tag ending in `_stable_`, and the image
tag ends in `-stable`. Take the step 8 archive first, then edit the image line in compose.yml:
```bash
cd /srv/duplicati
docker compose pull
docker compose up -d
docker compose logs --tail 30 duplicati
```
Duplicati migrates its settings database on the way up, and a version step can also rebuild a
job's file index on its first run, which is slow and looks like a hang. Watch the log until it
settles, then re-run step 7's checks before calling the update done.
## 10. What will probably go wrong
The page loads and then does nothing. I got the login screen over HTTPS, typed the password, and
watched a spinner turn with no error on screen; the container was healthy and the log was quiet.
Duplicati checks the Host header on every `/api/` call against a list that ships holding
localhost and nothing else, so the static files came back fine over the public hostname and every
request behind them came back `403`. The tell is the browser's network tab, not the log. Check
that the hostname in /srv/duplicati/.env matches the site block in /etc/caddy/Caddyfile, then
`docker compose up -d --force-recreate`.
## 11. Out of scope
- Do not install updates from inside the web UI. The version here is the image tag, and whatever
an in-place update writes into the container is gone at the next recreate.
- Do not set UID and GID on the container. It runs as root so it can read files under /srv that
other services wrote as other users.
- Do not mount /source read-write for restore in place. Restoring on top of a running service is
how a bad afternoon becomes a bad week; /restore exists for this.
- Do not configure SMTP or the upstream reporting console. Both are separate signups.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 Duplicati 2.3.0.4 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.
Read this before step 1. Duplicati is a backup client, not a place to put backups. It reads files
on the machine it runs on, encrypts them there, and uploads the pieces to a destination you
supply and pay for: a bucket, an SFTP account, a disk in a friend's house. What stops is a
subscription; what starts is a storage bill, or a second box of your own. And the machine it runs
on is this server, so the files in scope are /srv and whatever your other services keep in it,
not your laptop.
## 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 `1024` MB available, at least `5` G free, `amd64` or `arm64`, and your
server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does
not resolve, and failed attempts count against a rate limit you cannot see. If free disk is under
5 GB, stop and add disk. That 5 GB is the image, the temporary volumes Duplicati builds while
uploading, and a per-job database that grows with the number of files you track rather than with
their size.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/duplicati /srv/duplicati/backups
sudo install -d -m 700 /srv/duplicati/data /srv/duplicati/restore
ls -la /srv/duplicati
```
You should see: three directories, `backups` owned by you, and `data` and `restore` at mode
`drwx------` owned by root.
If you do not: leave the two root-owned ones alone. The container runs as root, because no UID
and GID pair is set for it and the files it reads under /srv were written by your other services
as other users. `data` is Duplicati's own settings database, which is a different thing from the
backups it makes: the image declares /data as a volume and points its config path there.
## 3. Secrets
Two secrets, generated here on the server, into a file only you can read. Do not paste the
contents of `.env`, or any command output containing one of these values, back into this chat
window. The chat you are reading is a third party; the file is not.
The first line is the security decision. Duplicati always has a web password: set none and it
generates a random one at first start, then writes a one-time sign-in link into the container
log, which is a credential sitting in `docker compose logs` on a public hostname. Setting the
password before the container has ever run replaces that with a value you own.
`SETTINGS_ENCRYPTION_KEY` is the one variable here with no `DUPLICATI__` prefix; it encrypts the
credential fields in the settings database, where your destination's access keys are about to
live. The third line is the hostname the API will answer for: the allowed list ships holding
localhost, 127.0.0.1 and bare IP addresses, so a Duplicati behind a real hostname refuses its own
front end until that name is added.
Replace `<DOMAIN>` on the last line with your hostname before you paste this.
```bash
umask 077
cat > /srv/duplicati/.env <<EOF
DUPLICATI__WEBSERVICE_PASSWORD=$(openssl rand -hex 24)
SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)
DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES=<DOMAIN>
EOF
chmod 600 /srv/duplicati/.env
umask 022
ls -l /srv/duplicati/.env
sudo grep DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES /srv/duplicati/.env
```
You should see: `-rw-------` on the listing, and your real hostname on the last line, bare, with
no scheme, no port and no angle brackets.
If you do not: if the last line still shows the placeholder, the substitution did not happen.
Fix the file before you go further, because the API answers 403 to every request through Caddy
until that value is right. Your web password is in that file and was never printed. Read it with
`sudo grep DUPLICATI__WEBSERVICE_PASSWORD /srv/duplicati/.env` and put both values in your
password manager today.
## 4. compose.yml
```bash
cat > /srv/duplicati/compose.yml <<'EOF'
# Duplicati · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker image readme . https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# image build ......... https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/Dockerfile
#
# One service. Duplicati is the backup client, not the storage behind it: it
# encrypts files here and uploads them to a destination you pay for
# separately. It runs as root, since no UID and GID pair is set and the files
# it reads under /srv belong to other services. .env carries the web password,
# the settings-database encryption key and the hostname the API answers for,
# because Duplicati refuses a request whose Host it does not know.
#
# Tag and digest read from docker.io on 2026-08-12; the manifest list covers
# amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
duplicati:
image: duplicati/duplicati:2.3.0.4-stable@sha256:01f8cb81ad7d548b7ceec61d696bb5d27d8057fee0ddee37c2b8a0ff1f1729f7
container_name: duplicati
restart: unless-stopped
env_file: /srv/duplicati/.env
environment:
# The container's clock zone. Schedules fire against it.
TZ: UTC
# Usage reporting ships on; this is upstream's own opt-out variable.
DO_NOT_TRACK: "1"
volumes:
# Duplicati-server.sqlite, the per-job databases, the JWT signing keys.
- /srv/duplicati/data:/data
# Everything this catalogue keeps, read only. Restores go to /restore.
- /srv:/source:ro
- /srv/duplicati/restore:/restore
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8188.
- "127.0.0.1:8188:8200"
healthcheck:
# /health needs no token and no allowed hostname; curl is in the image.
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8200/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
EOF
cd /srv/duplicati && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `docker compose config` prints the line it objected to. A heredoc that was pasted
through a chat window sometimes loses its indentation, and YAML cares. The most common damage is
the two-space indent in front of `duplicati:` and the four in front of `image:`. Delete the file
and paste the block again rather than fixing it by eye.
## 5. Caddy and TLS
Write the site block to its own file, then append it with your hostname substituted. The copy on
the first line is your undo: a syntax error takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-duplicati
cat > /srv/duplicati/Caddyfile <<'EOF'
# Duplicati · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That name is
# also DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES in .env: Duplicati answers 403
# to an API request whose Host is not on its list, and that list ships
# holding localhost and nothing else.
<DOMAIN> {
encode zstd gzip
# Duplicati sets no transport headers of its own. HSTS is on because
# every request here carries the token that reads this server.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8188 is the loopback port compose publishes here. It is not open in
# the firewall. The progress feed on /notifications is a WebSocket,
# which reverse_proxy upgrades with no extra directive.
reverse_proxy 127.0.0.1:8188
}
EOF
DUP_HOST=$(sudo grep DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES /srv/duplicati/.env | cut -d= -f2-)
echo "site block will be appended for $DUP_HOST"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|$DUP_HOST|g" /srv/duplicati/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: your real hostname after `site block will be appended for`, then
`Valid configuration` from `caddy validate`, then nothing at all from the reload.
If you do not: restore the copy with
`sudo cp /etc/caddy/Caddyfile.before-duplicati /etc/caddy/Caddyfile`, reload, and read what
validate objected to. The substitution deliberately reads the hostname back out of .env rather
than asking you twice, so the site block and the allowed-hostnames value cannot drift apart. If
the echo printed nothing, step 3 did not write the file.
## 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`, and rules for 80/tcp, 443/tcp and 443/udp. No rule mentioning
8188.
If you do not: if 8188 has a rule from an earlier attempt, remove it with
`sudo ufw delete allow 8188`. Compose binds that port to 127.0.0.1, so Caddy on this same box is
the only thing that can reach it, and opening it in the firewall would hand the login page to the
internet on plain HTTP. 80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the
way in, 443/udp is HTTP/3.
## 7. Start and verify
```bash
cd /srv/duplicati
docker compose pull
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v1/backups
printf '{"Password":"%s","RememberMe":false}' "$(sudo grep DUPLICATI__WEBSERVICE_PASSWORD /srv/duplicati/.env | cut -d= -f2-)" | curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://<DOMAIN>/api/v1/auth/login -H 'Content-Type: application/json' --data-binary @-
```
You should see: `Healthy`, then `401`, then `200`.
If you do not: the `401` is the security check, and it means the API refuses a call carrying no
token, so nobody who finds your hostname can read or edit a backup job. The `200` proves two
things at once: the generated password is the one the server holds, and the allowed-hostnames
value took effect, since that route rejects an unknown Host with `403` before it reads the body.
So a `403` on the last line points at step 3 or step 5, your hostname disagreeing between .env
and the Caddy block. A `401` on the last line means .env holds a password the container did not
start with, which happens if the container ran once before .env existed: `docker compose down`,
check the file, `docker compose up -d`. If the first command printed nothing at all, wait another
thirty seconds and run it again, then `docker compose logs --tail 40 duplicati`. A running
container is not success.
Now open https://<DOMAIN> in a browser. The first screen is one `Password` box: Duplicati has no
usernames and no second account. Read your password with
`sudo grep DUPLICATI__WEBSERVICE_PASSWORD /srv/duplicati/.env`, sign in, and do not go on until
you are looking at the inside of the application.
## 8. First backup and restore
Two different backups live in this step. The archive below backs up Duplicati itself, the
settings database that knows what to copy and where to send it. Your own first backup job is the
second half, and only you can make it.
```bash
cd /srv/duplicati
docker compose stop
sudo tar -czf /srv/duplicati/backups/duplicati-config-$(date +%F).tar.gz -C /srv/duplicati data compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/duplicati/backups/
```
You should see: one `.tar.gz` with a size in kilobytes or more, never `0`.
If you do not: an archive of a few hundred bytes usually means `data` was empty because the
container had not started yet. The stop is on purpose, because a SQLite database copied
mid-write is not a backup, and it costs a few seconds. That archive holds .env, so it holds the
key protecting everything else in it: keep it where you keep passwords.
Then copy it off the box. Run this on your own machine, not the server:
```bash
mkdir -p ~/backups/duplicati
scp vps:/srv/duplicati/backups/*.tar.gz ~/backups/duplicati/
```
You should see: the filename listed as it copies, and the same file in `~/backups/duplicati`.
If you do not: a backup on the same disk as the data is not a backup. If `scp` cannot find the
alias `vps`, use the hostname you gave the server in your ssh config.
Now the half only you can do. In the browser, choose `Add backup`, set a passphrase, pick a
destination, add `/source` as the source folder with `/source/duplicati` excluded, run the job
once, then restore one file from it into `/restore`. Two things worth knowing while you do it.
The passphrase encrypts every file before it leaves this box, nothing here can recover it, and
losing it makes the destination unreadable to you as much as to anyone else, so it goes in the
password manager beside the login. And the source box wants paths inside the container, where
this server's /srv is `/source`, while `/source/duplicati` is this app's own state and belongs in
the excludes rather than in the upload.
```bash
sudo ls -lR /srv/duplicati/restore
```
You should see: the file you restored, with a size greater than zero.
If you do not: an empty listing means the restore went somewhere else. Duplicati's restore screen
asks where to put the files, and `/source` cannot be written, which is deliberate: a restore
should never land on top of a running service. Point it at `/restore` and run it again. A backup
nobody has restored is a hope, and that listing is what turns it into a fact.
To restore Duplicati itself: `docker compose down`, `sudo rm -rf /srv/duplicati/data`, recreate
the directories as in step 2, untar the archive back into /srv/duplicati so .env is in place
before anything starts, put the Caddy block back if that was lost, then `docker compose up -d`
and re-run step 7.
## 9. Updating later
Upstream publishes three channels and this install pins stable, the slowest; beta and canary
carry higher version numbers on the same day and are where changes are tried out. Backups are the
wrong place to be early. Stable releases appear at
https://github.com/duplicati/duplicati/releases with a tag ending in `_stable_`, and the matching
image tag on Docker Hub ends in `-stable`. Take the step 8 archive first, then edit the image
line in /srv/duplicati/compose.yml to the new tag and its digest:
```bash
cd /srv/duplicati
docker compose pull
docker compose up -d
docker compose logs --tail 30 duplicati
```
You should see: the new image pulled, the container recreated, and a quiet log.
If you do not: Duplicati migrates its settings database on the way up, and a version step can
also rebuild a job's local file index on its first run after it, which is slow and looks like a
hang. Leave it alone and watch the log until it settles, then re-run step 7's three checks before
calling the update done.
## 10. What will probably go wrong
The page will load and then do nothing. I got the login screen over HTTPS, typed the password,
and watched a spinner turn with no error anywhere on screen; the container was healthy and the
log was quiet. Duplicati checks the Host header on every `/api/` call against an allowed list
that ships holding localhost and nothing else, so the static files came back fine over the public
hostname and every request behind them came back `403`. The tell is your browser's network tab,
not the container log. Check that the hostname in /srv/duplicati/.env matches the site block in
/etc/caddy/Caddyfile exactly, with no scheme and no port, then
`docker compose up -d --force-recreate`.
## 11. Out of scope
- Do not install updates from inside the web UI. The version here is the image tag, and whatever
an in-place update writes into the container is gone at the next recreate.
- Do not set UID and GID on the container. It runs as root so that it can read files under /srv
that your other services wrote as other users.
- Do not mount /source read-write for restore in place. Restoring on top of a running service is
how a bad afternoon becomes a bad week, and /restore exists for this.
- Do not configure SMTP or the upstream reporting console. Both are separate signups.305 lines · 14,975 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 Duplicati 2.3.0.4 under ~/selfhost/duplicati, answering at http://localhost:8188.
## 1. Preflight
Say this to the user before step 2 runs. Duplicati is a backup client, not a place to put
backups: it encrypts the files on this computer and uploads them to a destination the user
supplies, so what stops is a subscription and what starts is a storage bill. A schedule set for
3am runs only if this computer is awake then, so on a laptop the honest setting is a daily run
that fires when the lid opens.
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. Duplicati needs 1024 MB of RAM available and
5 GB free on the home disk; the image publishes amd64, arm64 and arm/v7. If either floor is
missed, print both 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/duplicati/data ~/selfhost/duplicati/restore ~/selfhost/duplicati/backups
ls -la ~/selfhost/duplicati
```
Assert: `ls -la` shows three folders owned by the user. On macOS and Windows, Docker Desktop's
file sharing owns permissions inside them; on Linux the container writes `data` as root, which is
why step 8 needs sudo there.
## 4. Secrets
Two secrets, generated on this machine, in hex because a human types one of them into a form. Do
not print either, do not repeat them in your summary, and keep them out of every log line. The
security decision is the first line. Duplicati always has a web password: set none and it
generates a random one at first start, then writes a one-time sign-in link into the container
log, leaving the way in inside `docker compose logs`. Setting the password first replaces that
with a value the user owns. `SETTINGS_ENCRYPTION_KEY`, the one variable with no `DUPLICATI__`
prefix, encrypts the credential fields in the settings database, where the destination's keys
are about to live.
```bash
cd ~/selfhost/duplicati
umask 077
cat > .env <<EOF
DUPLICATI__WEBSERVICE_PASSWORD=$(openssl rand -hex 24)
SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)
EOF
chmod 600 .env
umask 022
ls -l .env
```
Assert: the file exists at mode `-rw-------`. On Windows those mode bits are advisory and the
real boundary is the user's own account. Tell the user their password is readable with
`grep DUPLICATI__WEBSERVICE_PASSWORD ~/selfhost/duplicati/.env`, and that both values go in their
password manager today.
## 5. compose.yml
```bash
cat > ~/selfhost/duplicati/compose.yml <<'EOF'
# Duplicati · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker image readme . https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# image build ......... https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/Dockerfile
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/duplicati/, which lets one file work on macOS, Linux and Windows.
# ../.. is the home folder two levels up, read only: the files this install
# exists to copy somewhere safe. No allowed-hostnames value is set here,
# because localhost and bare IP addresses are on the API's list already.
#
# Tag and digest read from docker.io on 2026-08-12; the manifest list covers
# amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
duplicati:
image: duplicati/duplicati:2.3.0.4-stable@sha256:01f8cb81ad7d548b7ceec61d696bb5d27d8057fee0ddee37c2b8a0ff1f1729f7
container_name: duplicati
restart: unless-stopped
env_file: .env
environment:
# The container's clock zone. Schedules fire against it.
TZ: UTC
# Usage reporting ships on; this is upstream's own opt-out variable.
DO_NOT_TRACK: "1"
volumes:
# Duplicati-server.sqlite, the per-job databases, the JWT signing keys.
- ./data:/data
# Your home folder, read only. Restores go to ./restore instead.
- ../..:/source:ro
- ./restore:/restore
ports:
# Loopback only: no other device on the wifi can reach 8188.
- "127.0.0.1:8188:8200"
healthcheck:
# /health needs no token and no allowed hostname; curl is in the image.
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8200/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
EOF
cd ~/selfhost/duplicati && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one port, three mounts, no database.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule, and each is a decision. There is no hostname
to resolve, and a certificate attests a public name; browsers treat http://localhost as a secure
context, so the login form works. Nothing is published beyond loopback, so no port needs closing,
and 8188 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not anyone on the
internet. A backup tool loses little to that: the uploads still reach the destination. Confirm
the binding:
```bash
grep -c '"127.0.0.1:' ~/selfhost/duplicati/compose.yml
```
Assert: that prints `1`. If it prints `0`, the compose file was edited and the port is open to
the whole network; stop and put the binding back.
## 7. Start and verify
```bash
cd ~/selfhost/duplicati
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8188/health); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8188/health; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8188/api/v1/backups
printf '{"Password":"%s","RememberMe":false}' "$(grep DUPLICATI__WEBSERVICE_PASSWORD ~/selfhost/duplicati/.env | cut -d= -f2-)" | curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8188/api/v1/auth/login -H 'Content-Type: application/json' --data-binary @-
docker compose exec -T duplicati ls /source | head -5
```
Assert all five, and print what you received for each. The loop ends printing `200`. The second
prints `Healthy`. The third prints `401`, the security assert here: the API refuses a call with
no token, so anything else on this machine that finds the port is locked out. The fourth prints
`200`, proving the generated password is the one the container holds; it reaches curl on standard
input, never a command line. The fifth prints names from the home folder: an empty listing means
the container cannot read through that mount, and there is nothing worth backing up until it can.
If any of the five misses, stop, run `docker compose logs --tail 40 duplicati`, and name the
cause. A `401` on the fourth means .env holds a password the container did not start with, which
happens if it ran before .env existed: `docker compose down`, then `up -d`. If
`port is already allocated` came back, find what holds 8188 (`lsof -nP -iTCP:8188 -sTCP:LISTEN`,
or `netstat -ano | findstr :8188` on Windows). A running container is not success.
STOP: tell the user to read their password with
`grep DUPLICATI__WEBSERVICE_PASSWORD ~/selfhost/duplicati/.env`, put it in their password
manager, open http://localhost:8188, and sign in. Do not continue until they confirm. The first
screen is one `Password` box: there are no usernames and no second account.
## 8. First backup and restore
Two backups live in this step. The archive below backs up Duplicati itself, the settings
database that knows what to copy and where to send it. The user's own first job is the other.
```bash
cd ~/selfhost/duplicati
docker compose stop
if [ "$(uname -s)" = "Linux" ]; then SUDO=sudo; else SUDO=""; fi
$SUDO tar -C ~/selfhost/duplicati -czf ~/selfhost/duplicati/backups/duplicati-config-$(date +%F).tar.gz data compose.yml .env
docker compose start
ls -lh ~/selfhost/duplicati/backups/
```
Assert: the archive exists and is non-empty. Print its size. The stop is on purpose: a SQLite
database copied mid-write is not a backup, and it costs a few seconds. On Linux the container
wrote `data` as root, which is what the `SUDO` line is for; on macOS and Windows Docker Desktop
hands those files to the user's own account and it stays empty. The archive holds .env, so it
holds the key protecting the rest of it.
That archive is 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 it there with `cp`; in Git Bash a Windows drive is `/d/Backups`. Assert: the user
confirms the file is there.
STOP: tell the user to open http://localhost:8188, choose `Add backup`, set a passphrase, pick a
destination, add `/source` as the source folder with `/source/selfhost/duplicati` excluded, run
the job once, then restore one file from it into `/restore`. Do not continue until they confirm.
Two things while they do it. The passphrase encrypts every file before it leaves this machine,
nothing here can recover it, and losing it makes the destination unreadable to them as much as to
anyone else. And the folder picker shows container paths: home is `/source`, so Documents is
`/source/Documents`.
```bash
ls -lR ~/selfhost/duplicati/restore
```
Assert: the restored file is there and non-empty. Print the listing. A backup nobody has restored
is a hope, and that turns it into a fact. To restore Duplicati itself: `docker compose down`, set
`SUDO` again the same way, then `$SUDO rm -rf ~/selfhost/duplicati/data`, then
`$SUDO tar -xzf ~/selfhost/duplicati/backups/<archive> -C ~/selfhost/duplicati`, then
`docker compose up -d` and re-run step 7. That archive carries .env, so the password is back in
place before anything starts.
## 9. Updating later
Upstream publishes three channels and this pins stable, the slowest; beta and canary carry
higher numbers on the same day and are where changes are tried out, and backups are the wrong
place to be early. Stable releases appear at https://github.com/duplicati/duplicati/releases with
a tag ending in `_stable_`, and the image tag ends in `-stable`. Take the step 8 archive first,
then edit the image line in compose.yml:
```bash
cd ~/selfhost/duplicati
docker compose pull
docker compose up -d
docker compose logs --tail 30 duplicati
```
Duplicati migrates its settings database on the way up, and a version step can rebuild a job's
file index on its first run, which is slow and looks like a hang. Watch the log until it settles,
then re-run step 7's checks.
## 10. What will probably go wrong
Nothing will run, and the screen will not say so. I rebooted, Docker Desktop did not come back
with the machine, and nine days later the dashboard opened on a last-run date recent enough to
skim past without reading the year on it. A tool switched off looks exactly like one with nothing
to do. Turn on Docker Desktop's start-at-login setting, run
`cd ~/selfhost/duplicati && docker compose up -d` after any reboot, and read the last successful
run date once a month.
## 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 install updates from inside the web UI. The version here is the image tag, and an
in-place update is gone at the next recreate.
- Do not mount /source read-write. Restoring on top of live files is how a bad afternoon becomes
a bad week; /restore exists for this.compose.local.ymlthe services, pinned · local layout43 lines
# Duplicati · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker image readme . https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# image build ......... https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/Dockerfile
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/duplicati/, which lets one file work on macOS, Linux and Windows.
# ../.. is the home folder two levels up, read only: the files this install
# exists to copy somewhere safe. No allowed-hostnames value is set here,
# because localhost and bare IP addresses are on the API's list already.
#
# Tag and digest read from docker.io on 2026-08-12; the manifest list covers
# amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
duplicati:
image: duplicati/duplicati:2.3.0.4-stable@sha256:01f8cb81ad7d548b7ceec61d696bb5d27d8057fee0ddee37c2b8a0ff1f1729f7
container_name: duplicati
restart: unless-stopped
env_file: .env
environment:
# The container's clock zone. Schedules fire against it.
TZ: UTC
# Usage reporting ships on; this is upstream's own opt-out variable.
DO_NOT_TRACK: "1"
volumes:
# Duplicati-server.sqlite, the per-job databases, the JWT signing keys.
- ./data:/data
# Your home folder, read only. Restores go to ./restore instead.
- ../..:/source:ro
- ./restore:/restore
ports:
# Loopback only: no other device on the wifi can reach 8188.
- "127.0.0.1:8188:8200"
healthcheck:
# /health needs no token and no allowed hostname; curl is in the image.
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8200/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30sagent-readable mirror: /self-host/idrive.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, pinned44 lines
# Duplicati · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker image readme . https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# image build ......... https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/Dockerfile
#
# One service. Duplicati is the backup client, not the storage behind it: it
# encrypts files here and uploads them to a destination you pay for
# separately. It runs as root, since no UID and GID pair is set and the files
# it reads under /srv belong to other services. .env carries the web password,
# the settings-database encryption key and the hostname the API answers for,
# because Duplicati refuses a request whose Host it does not know.
#
# Tag and digest read from docker.io on 2026-08-12; the manifest list covers
# amd64, arm64 and arm/v7.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
duplicati:
image: duplicati/duplicati:2.3.0.4-stable@sha256:01f8cb81ad7d548b7ceec61d696bb5d27d8057fee0ddee37c2b8a0ff1f1729f7
container_name: duplicati
restart: unless-stopped
env_file: /srv/duplicati/.env
environment:
# The container's clock zone. Schedules fire against it.
TZ: UTC
# Usage reporting ships on; this is upstream's own opt-out variable.
DO_NOT_TRACK: "1"
volumes:
# Duplicati-server.sqlite, the per-job databases, the JWT signing keys.
- /srv/duplicati/data:/data
# Everything this catalogue keeps, read only. Restores go to /restore.
- /srv:/source:ro
- /srv/duplicati/restore:/restore
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8188.
- "127.0.0.1:8188:8200"
healthcheck:
# /health needs no token and no allowed hostname; curl is in the image.
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8200/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30sCaddyfilethe hostname and TLS30 lines
# Duplicati · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That name is
# also DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES in .env: Duplicati answers 403
# to an API request whose Host is not on its list, and that list ships
# holding localhost and nothing else.
<DOMAIN> {
encode zstd gzip
# Duplicati sets no transport headers of its own. HSTS is on because
# every request here carries the token that reads this server.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8188 is the loopback port compose publishes here. It is not open in
# the firewall. The progress feed on /notifications is a WebSocket,
# which reverse_proxy upgrades with no extra directive.
reverse_proxy 127.0.0.1:8188
}install.shthe same install, no agent169 lines
#!/usr/bin/env bash
# Duplicati · 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=backup.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/README.md
# https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/ReleaseBuilder/Resources/Docker/Dockerfile
# https://github.com/duplicati/duplicati/blob/v2.3.0.4_stable_2026-07-09/Duplicati/WebserverCore/Services/HostnameValidator.cs
#
# Two secrets are generated here, on this machine, into /srv/duplicati/.env at
# mode 600, and neither is printed. The first one is the web password. A
# Duplicati started without one generates a random password and writes a
# one-time sign-in link into the container log, which is a credential sitting
# in `docker compose logs` on a public hostname. The second encrypts the
# credential fields inside the settings database, where the storage
# destination's access keys will live.
#
# The backup passphrase you type into the UI later is a different thing again,
# and nothing here can recover it. Keep it where you keep this login.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/duplicati}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
PORT="8188"
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. backup.example.com"
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 1024 ] || die "only ${avail_mb} MB of RAM available; Duplicati wants 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 5 ] || die "only ${avail_gb} GB free on /srv; this install wants 5 GB for the image, the upload temp files and the job database"
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 ----------------------------------------------------
#
# backups belongs to the login user; data and restore are written by the
# container, which runs as root because the files it reads under /srv were
# written by other services as other users.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 700 "$APP_DIR/data" "$APP_DIR/restore"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64, because one of them is typed into a login form. Read
# the web password later with
# sudo grep DUPLICATI__WEBSERVICE_PASSWORD /srv/duplicati/.env
# The third line holds nothing private. It is the hostname the API answers for,
# and Duplicati's allowed-hostname list ships holding localhost, 127.0.0.1 and
# bare IP addresses, so without it every request through Caddy comes back 403.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
DUPLICATI__WEBSERVICE_PASSWORD=$(openssl rand -hex 24)
SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)
DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES=${DOMAIN_HOST}
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
env_host="$(sudo grep DUPLICATI__WEBSERVICE_ALLOWED_HOSTNAMES "$APP_DIR/.env" | cut -d= -f2- || true)"
echo "==> allowed hostname in .env: ${env_host}"
[ "$env_host" = "$DOMAIN_HOST" ] || die "the .env allowed hostname is '${env_host}', not '${DOMAIN_HOST}'. An older .env is in place; move it aside and run this again."
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-duplicati"
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 8188 is not one of them -------------------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; ${PORT} stays closed on loopback"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/health"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/health" || true)"
[ "$code" = "200" ] && break
sleep 5
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/health answered ${code:-nothing}. Check: docker compose logs --tail 40 duplicati"
health="$(curl -sS "https://${DOMAIN_HOST}/health" || true)"
echo "==> /health said: ${health}"
[ "$health" = "Healthy" ] || die "/health returned '${health}', not 'Healthy'. Caddy may be reaching something other than Duplicati."
# The API must refuse a call with no token. A 200 here would mean the request
# never reached Duplicati, because this build has no way to turn auth off.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/backups" || true)"
echo "==> unauthenticated /api/v1/backups: ${unauth}"
[ "$unauth" = "401" ] || die "an unauthenticated API call returned ${unauth}, not 401. Stop and find out what is answering."
# And it must accept the generated password. The value goes to curl on standard
# input, so it never appears in a command line. A 403 here means the hostname in
# .env and the Caddy site block disagree.
authed="$(printf '{"Password":"%s","RememberMe":false}' "$(sudo grep DUPLICATI__WEBSERVICE_PASSWORD "$APP_DIR/.env" | cut -d= -f2-)" \
| curl -sS -o /dev/null -w '%{http_code}' -X POST "https://${DOMAIN_HOST}/api/v1/auth/login" \
-H 'Content-Type: application/json' --data-binary @- || true)"
echo "==> login with the generated credential returned: ${authed}"
[ "$authed" = "200" ] || die "the generated login returned ${authed}, not 200. A 403 means the allowed hostname and the Caddy block disagree; a 401 means the container started before .env existed."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/duplicati-config-${STAMP}.tar.gz" -C "$APP_DIR" data compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/duplicati-config-${STAMP}.tar.gz" ] || die "the configuration archive is empty"
cat <<-DONE
Duplicati is answering at https://${DOMAIN_HOST}/ and refusing unauthenticated API calls.
1. There is no username. The password is in $APP_DIR/.env, mode 600. Read it with
sudo grep DUPLICATI__WEBSERVICE_PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here.
2. Nothing is backed up yet. Open https://${DOMAIN_HOST}/, sign in, then Add backup:
set a passphrase, pick a destination, and add /source as the source folder with
/source/duplicati excluded. This server's /srv is /source inside the container,
and /source/duplicati is this app's own state.
3. That passphrase encrypts every file before it leaves this box. Nothing here can
recover it, so losing it makes the destination unreadable to you too.
4. Restore one file from your first job into /restore and look at it with
sudo ls -lR $APP_DIR/restore
A backup nobody has restored is a hope.
5. First backup of this install written to $APP_DIR/backups. It holds .env, so it
holds the key protecting the rest of it, and it is on the same disk as the data,
which is not a backup. Copy it somewhere else tonight.
DONEWhat 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 IDrive.
- You stop paying a subscription and start paying for storage, separately. IDrive sells the client and the terabytes on one invoice; Duplicati is only the client, so the backups have to land somewhere you supply: object storage billed by the gigabyte every month, an SFTP account, or a second machine you keep running. Below a few hundred gigabytes that usually beats a 5 TB plan you are not filling, and at several terabytes it often does not. Do the arithmetic against IDrive's standard price rather than its first-year one, because the first year is the number designed to be compared.
- The passphrase is unrecoverable, on purpose. Duplicati encrypts with AES-256 on your machine before anything is uploaded, which is the reason a bucket at a company you have never met is an acceptable place to put your files, and also the reason a forgotten passphrase turns every one of those files into noise for you as much as for anyone else. It belongs in a password manager the day you type it, next to the archive of Duplicati's own settings database.
- You become the person who tests restores. Nothing here emails you when a job has not run in three weeks, no phone app finds a file for you from the car, and no courier arrives with a drive when the restore is four terabytes. The install ends by making you restore one file, and that habit is the actual product. It also copies files rather than understanding them, so another service's database picked up mid-write can restore unreadable; where a service can write its own dump, back that up instead of its live data directory.
- MIT with a carve-out worth knowing. The LICENSE at the pinned tag is MIT, copyright Duplicati Inc, and it excludes the proprietary/ directory, which carries its own licence requiring a Duplicati Inc subscription for production use. That directory holds the Microsoft 365 and Google Workspace connectors, disk-image support and the licence checker, and the company sells a hosted monitoring console alongside them. Nothing in this install touches those, and the core you run here is MIT.
- This backs up the machine it runs on. On a server that is the server's data; on your own computer it is your own files. It is not an agent for the household's other laptops, and IDrive's plans covering several computers under one account have no equivalent here without running this once per machine.
Where this came from
“On the first launch, Duplicati will generate the database containing the server settings. This includes a signing key for JWT tokens and a randomly generated password for accessing the UI.”
- The published image sets XDG_CONFIG_HOME to /data, declares /data as a volume, exposes 8200, and sets DUPLICATI__WEBSERVICE_PORT to 8200 and DUPLICATI__WEBSERVICE_INTERFACE to any, so the web interface listens on every address inside the container rather than on loopback. source
- The image entrypoint creates a user and drops privileges only when both UID and GID are supplied; with neither set it execs the command directly, so by default the container runs as root. source
- A Duplicati that starts with no password set generates a random one and logs a one-time sign-in link; upstream's own remedy is to supply DUPLICATI__WEBSERVICE_PASSWORD, and it recommends setting the allowed hostnames and SETTINGS_ENCRYPTION_KEY to reduce the risk of leaking credentials out of the settings database. source
- Command line options become environment variables by prefixing DUPLICATI__ and turning dashes into underscores; the settings-encryption key is the exception and is read from the unprefixed SETTINGS_ENCRYPTION_KEY. source
- The hostname allow-list ships holding localhost, 127.0.0.1, [::1] and localhost.localdomain, and any value that parses as an IP address is accepted; every other Host fails validation until it is added. source
- That hostname filter is attached to the /api/v1 and /api/v2 route groups, which is why the static files and the /health endpoint answer over a public hostname while every API call behind them does not. source
- The refusal is HTTP 403: the exception the filter throws declares that status code. source
- Anonymous usage reporting is on by default at the Information level, and the reporter checks DO_NOT_TRACK=1 before anything is sent. source
- The LICENSE at this tag is MIT, copyright Duplicati Inc, with an explicit carve-out: everything under the proprietary/ directory is licensed by proprietary/LICENSE instead, which requires a Duplicati Inc subscription for production use. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host IDrive?
Not IDrive 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 Duplicati. A backup client with a web interface: schedules, versions, and AES-256 applied on your own machine before anything is uploaded to storage you chose. The install is one evening: one container 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 90 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 IDrive?
Duplicati. A backup client with a web interface: schedules, versions, and AES-256 applied on your own machine before anything is uploaded to storage you chose. The closest answer to the shape IDrive sells, as long as you follow the money. Duplicati is a backup client with a web UI: schedules, versioned incremental backups, AES-256 encryption applied on your own machine before anything is uploaded, and a backend list at the pinned tag that runs from S3-compatible object storage, Backblaze B2, Azure, Google, OneDrive and Dropbox through to SFTP, FTP, WebDAV, SMB and rclone. IDrive's own e2 object storage is on that list too, which is one honest way to keep the vendor and change the product. What Duplicati does not include is the storage, so a single invoice becomes a subscription you stop paying and a per-gigabyte bill you start paying, or a second machine you run yourself. Below a few hundred gigabytes that usually comes out ahead of a 5 TB plan you are not filling; at several terabytes the arithmetic gets closer, and you should do it before you switch rather than after. The encryption passphrase is yours alone, which is the good news and the bad news. Also evaluated and ranked below it: Backrest. The prompt on this page installs Duplicati only. Duplicati is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to IDrive?
1024 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Duplicati itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: IDrive IDrive Personal · 5 TB, $9.99/mo — $119.88 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 Duplicati install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Duplicati 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 Duplicati 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. Worth knowing: This is the path Duplicati was built for, backing up the computer you are sitting at, with one consequence worth saying out loud: a job scheduled for 3am runs only if the machine is awake at 3am, so on a laptop the honest setting is a daily run that fires when the lid opens. 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-14. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.