# Can I self-host Wanderlog?

**YES** — it's called AdventureLog. ONE EVENING setup · ~2 hours to running · 2 GB RAM minimum · $3.33/mo you stop paying ($39.96/yr on the Pro plan).

AdventureLog authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/wanderlog-pro/

## Install prompt (Claude Code)

````text
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 AdventureLog 0.12.1 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. That hostname becomes `ORIGIN`, `PUBLIC_URL`,
`FRONTEND_URL` and `CSRF_TRUSTED_ORIGINS` in one .env file and fronts every image URL, so
changing it later is an edit in four places.

AdventureLog needs 2048 MB of RAM available and 10 GB free on /srv. Upstream asks for 2 GB on the
first boot, which imports the world geography dataset, and about 1 GB after. Both AdventureLog
images publish amd64 and arm64, but PostGIS is required and postgis/postgis publishes amd64 only,
so this install is amd64 only. Measure all four first:

```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 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope: on a smaller box the OOM killer ends the world-data import and the
container exits 137. If `dpkg --print-architecture` prints anything but `amd64`, print it and
stop, because there is no PostGIS image for that architecture. If `dig +short` prints nothing,
print that and stop, because Caddy cannot get a certificate for a name that does not resolve and
failed attempts count against a rate limit.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/adventurelog /srv/adventurelog/backups
sudo install -d -m 755 /srv/adventurelog/media
sudo install -d -m 700 /srv/adventurelog/postgres
ls -la /srv/adventurelog
```

Assert: `ls -la` shows `backups` owned by the login user, `media` at mode `drwxr-xr-x` and
`postgres` at mode `drwx------`, the last two owned by root. Leave both to root: the backend
container runs as root and writes photographs and flags into `media`, and the database image
chowns its own data directory on first start, so one already chowned to yourself makes it refuse
to initialise.

## 3. Secrets

Three secrets, all generated here: the PostgreSQL password, Django's `SECRET_KEY`, and the
password for the `admin` account the backend creates on first boot. Do not print any of them, do
not repeat them in your summary, and keep them out of every log line. Hex, not base64: one
travels inside a database connection string.

```bash
umask 077
cat > /srv/adventurelog/.env <<EOF
PUBLIC_SERVER_URL=http://server:8000
ORIGIN=https://<DOMAIN>
BODY_SIZE_LIMIT=Infinity
PGHOST=db
POSTGRES_DB=adventurelog
POSTGRES_USER=adventurelog
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_KEY=$(openssl rand -hex 48)
DJANGO_ADMIN_USERNAME=admin
DJANGO_ADMIN_PASSWORD=$(openssl rand -hex 24)
DJANGO_ADMIN_EMAIL=admin@<DOMAIN>
PUBLIC_URL=https://<DOMAIN>
FRONTEND_URL=https://<DOMAIN>
CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
DEBUG=False
DISABLE_REGISTRATION=True
ENABLE_RATE_LIMITS=True
EOF
chmod 600 /srv/adventurelog/.env
umask 022
ls -l /srv/adventurelog/.env
```

Assert: the file exists with mode `-rw-------`. Four lines matter. `PUBLIC_SERVER_URL` is how
the frontend reaches the backend inside the network, and upstream says not to change it.
`DEBUG` defaults to true in the image, so False here stops Django serving stack traces to
strangers. `DISABLE_REGISTRATION` closes a sign-up form otherwise open on a public hostname.
`ENABLE_RATE_LIMITS` defaults to false and turns on upstream's throttle for failed logins. No
mail is configured, so `DJANGO_ADMIN_EMAIL` is a label, not a mailbox.

## 4. compose.yml

```bash
cat > /srv/adventurelog/compose.yml <<'EOF'
# AdventureLog · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .. https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/docker.md
#   variables ....... https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/.env.example
#
# Three services, and the names are load bearing: PUBLIC_SERVER_URL defaults to
# http://server:8000 and PGHOST is db. Two host ports, because the browser talks
# to both containers: the Django backend answers /media, /admin, /static and
# /accounts, the frontend answers the rest, the split upstream's Caddy guide
# documents. PostGIS is required and postgis/postgis publishes linux/amd64 only.
# Digests read from the registries on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgis/postgis:16-3.5@sha256:7d7925e334fceb6079c0a5d150e925f192cde2cf1dd78767ca843e2996d39829
    platform: linux/amd64
    container_name: adventurelog-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/adventurelog/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U adventurelog -d adventurelog"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  server:
    image: ghcr.io/seanmorley15/adventurelog-backend:v0.12.1@sha256:7c759efab1476841f7319776666e527bedd481cd71dbb08e51aaa5959f2a28eb
    container_name: adventurelog-backend
    restart: unless-stopped
    env_file: /srv/adventurelog/.env
    volumes:
      # Photographs, and the country flags the first boot downloads.
      - /srv/adventurelog/media:/code/media
    ports:
      # Loopback only: Caddy sends /media, /admin, /static and /accounts here.
      - "127.0.0.1:8268:80"
    depends_on:
      db:
        condition: service_healthy

  web:
    image: ghcr.io/seanmorley15/adventurelog-frontend:v0.12.1@sha256:edd79220f0def1dbea5b5d56636621f6cfdb454db9c00a8ce436a8ab489c5e99
    container_name: adventurelog-frontend
    restart: unless-stopped
    env_file: /srv/adventurelog/.env
    ports:
      # Loopback only: Caddy sends everything else here.
      - "127.0.0.1:8168:3000"
    depends_on:
      - server
EOF
cd /srv/adventurelog && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Three services, two published ports, two bind mounts.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-adventurelog
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# AdventureLog · the Caddy site block for this service. Authored by
# caniselfhostit from
# https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/caddy.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# ORIGIN, PUBLIC_URL, FRONTEND_URL and CSRF_TRUSTED_ORIGINS in .env, so all five
# stay the same string or the login form answers 403.
#
# One hostname, two upstreams, the split upstream's Caddy guide documents: the
# Django backend answers /media, /admin, /static and /accounts, the frontend
# answers everything else. Send it all to the frontend and every photograph
# goes missing.

<DOMAIN> {
	# The app is a JavaScript bundle and the API answers JSON.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: the map draws tiles from basemaps.cartocdn.com
	# and the flags come from flagcdn.com, so one written untested breaks maps.

	# 8268 and 8168 are loopback ports compose publishes, closed in the firewall.
	@backend path /media* /admin* /static* /accounts*
	reverse_proxy @backend 127.0.0.1:8268

	reverse_proxy 127.0.0.1:8168
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-adventurelog, reload, and report what it objected to. Caddy issues
and renews the certificate itself, and sets the `X-Forwarded-Proto` Django reads for https.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```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. 8168 and 8268 stay closed because compose binds both to 127.0.0.1, 5432 because compose
never publishes it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8168, 8268 or 5432.

## 7. Start and verify

The backend waits for PostgreSQL, runs its migrations, creates the `admin` account from the three
`DJANGO_ADMIN_` values, then downloads the world country and region dataset and a flag for every
country before it serves anything. On a fresh box that last part is minutes, so the loop below is
patient.

```bash
cd /srv/adventurelog
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/admin/login/
curl -sS http://127.0.0.1:8268/auth/is-registration-disabled/
docker compose exec -T db psql -U adventurelog -d adventurelog -tAc "SELECT count(*) FROM worldtravel_country;"
```

Assert all five, and print what you received for each: the loop ends on `200`; the title line
prints `<title>AdventureLog</title>`; the Django admin login page answers `200`, which proves
Caddy routes the four backend paths to 8268 rather than sending everything to the frontend; the
registration endpoint prints `"is_disabled":true`, the security assert; the count is at least
`195`, meaning the world-data import finished rather than being killed. If any of the five
misses, stop, run `docker compose logs --tail 40 server` and `docker compose logs --tail 20 db`,
and name the likely earlier step: a database that never reports healthy points at step 2, `502`
means Caddy reaches nothing on 8168, `404` on the admin page means step 5's `@backend` matcher
did not land, exit code 137 is the OOM killer during the import and step 1's floor being wrong.
A running container is not success.

The first screen at https://<DOMAIN> is the AdventureLog landing page with a `Login` button.
https://<DOMAIN>/login shows a form with `Username` and `Password` boxes and no sign-up link.

STOP: tell the user to read their admin password with
`sudo grep DJANGO_ADMIN_PASSWORD /srv/adventurelog/.env`, put it in their password manager, sign
in at https://<DOMAIN>/login as `admin`, confirm the dashboard loads, and wait.
Do not continue until they confirm. It is the only credential here, and no mail server can send
a reset link.

## 8. First backup and restore

Two artifacts: the database holds every trip, location and visit, the config archive holds the
photographs and the three files that rebuild the service around them.

```bash
cd /srv/adventurelog
docker compose exec -T db pg_dump -U adventurelog -d adventurelog | gzip > /srv/adventurelog/backups/adventurelog-db-$(date +%F).sql.gz
sudo tar -czf /srv/adventurelog/backups/adventurelog-config-$(date +%F).tar.gz -C /srv/adventurelog compose.yml .env media -C /etc/caddy Caddyfile
ls -lh /srv/adventurelog/backups/
```

Assert: both exist and both are non-empty. Print both sizes. The config archive is tens of
megabytes on a fresh install because the flags are in it. Nothing is stopped: `pg_dump` snapshots
a running database consistently. A backup on the same disk is not a backup, so run this one from
the user's machine, not the server:

```bash
mkdir -p ~/backups/adventurelog
scp vps:/srv/adventurelog/backups/* ~/backups/adventurelog/
```

To restore: `docker compose down`, `sudo rm -rf /srv/adventurelog/postgres`, recreate it as in
step 2, untar the config archive into /srv/adventurelog so .env is back before anything starts,
`docker compose up -d db`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db psql -U adventurelog -d adventurelog`, then `docker compose up -d`.
Tell the user what matters at 2am: the photographs are files in `media`, what says which trip
they belong to is rows in the database, and the two archives are worth something only together.

## 9. Updating later

New versions are listed at https://github.com/seanmorley15/AdventureLog/releases. Migrations run
at start-up and upstream asks you to back up first, so take both artifacts, then edit the two
image lines in /srv/adventurelog/compose.yml to the new tags and digests:

```bash
cd /srv/adventurelog
docker compose pull
docker compose up -d
docker compose logs --tail 30 server
```

Watch that log until the migrations settle, then re-run all five checks from step 7. This project
is on 0.x numbers and ships a few releases a year, so read the release notes first: a minor bump
here is not always a small one.

## 10. What will probably go wrong

The first `docker compose up -d` looks like a hang, and I nearly restarted it. It is not: before
the backend serves one request it downloads a world dataset of countries, regions and cities,
then fetches a flag for every country in turn, and on a small VPS that took me several minutes
with nothing on screen but a container that would not answer. Let step 7's loop run all sixty
attempts before touching anything. The identical-looking failure is worth checking afterwards: if
`docker compose logs server` ends with exit code 137, the import was killed for memory, and the
fix is a bigger box rather than a retry.

## 11. Out of scope

- Do not set `GOOGLE_MAPS_API_KEY`. Place search falls back to OpenStreetMap's Nominatim without
  it, and the alternative is a Google Cloud project with billing attached.
- Do not configure SMTP or set `EMAIL_BACKEND`. Email verification ships off, the one account
  here is already verified, and mail from a fresh VPS is its own week of work.
- Do not enable social login, the Strava integration or the Immich integration. Each means
  registering an application somewhere else, and none is needed to log a trip.
- Do not remove `DISABLE_REGISTRATION`. On a public hostname that reopens the sign-up form.
````

## Chat fallback

````text
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 AdventureLog 0.12.1 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.

One thing to understand before you start, because it explains half of what can go wrong here.
AdventureLog is three containers, not one: a SvelteKit frontend, a Django backend and a PostGIS
database. The browser talks to two of them. The backend answers `/media`, `/admin`, `/static` and
`/accounts`, the frontend answers everything else, and Caddy is what splits the traffic between
them on a single hostname. That is why step 4 publishes two loopback ports instead of one.

## 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 `2048` MB available, at least `10` G free, `amd64`, and your server's IP
on the last line.

If you do not: `amd64` is not negotiable. PostGIS is required by AdventureLog and the
postgis/postgis image publishes amd64 only, so an ARM box stops here. The 2048 MB is not padding
either, because the first boot imports a world geography dataset and gets killed for memory on a
smaller machine. An empty last line means the A record does not exist yet: add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/adventurelog /srv/adventurelog/backups
sudo install -d -m 755 /srv/adventurelog/media
sudo install -d -m 700 /srv/adventurelog/postgres
ls -la /srv/adventurelog
```

You should see: `backups` owned by you, `media` at mode `drwxr-xr-x` owned by root, and
`postgres` at mode `drwx------` owned by root.

If you do not: leave the last two owned by root on purpose. The backend container runs as root
and writes your photographs and the country flags into `media`. The PostgreSQL image chowns its
own data directory the first time it starts, and one you have already chowned to yourself makes
it refuse to initialise.

## 3. Secrets

Three secrets, all generated here on the server: the PostgreSQL password, Django's `SECRET_KEY`,
and the password for the `admin` account the backend creates on first boot. All three go into a
file only you can read. Replace `<DOMAIN>` on four of these lines with your real hostname before
you paste.

```bash
umask 077
cat > /srv/adventurelog/.env <<EOF
PUBLIC_SERVER_URL=http://server:8000
ORIGIN=https://<DOMAIN>
BODY_SIZE_LIMIT=Infinity
PGHOST=db
POSTGRES_DB=adventurelog
POSTGRES_USER=adventurelog
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_KEY=$(openssl rand -hex 48)
DJANGO_ADMIN_USERNAME=admin
DJANGO_ADMIN_PASSWORD=$(openssl rand -hex 24)
DJANGO_ADMIN_EMAIL=admin@<DOMAIN>
PUBLIC_URL=https://<DOMAIN>
FRONTEND_URL=https://<DOMAIN>
CSRF_TRUSTED_ORIGINS=https://<DOMAIN>
DEBUG=False
DISABLE_REGISTRATION=True
ENABLE_RATE_LIMITS=True
EOF
chmod 600 /srv/adventurelog/.env
umask 022
ls -l /srv/adventurelog/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Read the admin password
once with `sudo grep DJANGO_ADMIN_PASSWORD /srv/adventurelog/.env` and put it in your password
manager. It is the only credential this install has, and no mail server here can send you a reset
link.

Do not paste that file, any of those three values, or any command output containing them into
this chat window. The agent path never sees them; this path will hand them to a third party
unless you keep them out of the box you are typing in.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens when
you paste the lines separately in different shells. Run `chmod 600 /srv/adventurelog/.env` and
carry on. Four of these lines are doing real work and are worth understanding.
`PUBLIC_SERVER_URL` is how the frontend reaches the backend inside the Docker network, and
upstream tells you not to change it. `DEBUG` defaults to true in the image, so setting it to
False here is what stops Django serving stack traces to strangers. `DISABLE_REGISTRATION` closes
a sign-up form that would otherwise be open to anyone who finds your hostname.
`ENABLE_RATE_LIMITS` defaults to false and switches on the throttle upstream ships for failed
logins. And if the file already existed from an earlier attempt, this block has now overwritten
all three secrets, which is fine before the database exists and a problem afterwards: the
database keeps the password it was created with, so a changed `POSTGRES_PASSWORD` on an existing
volume shows up as the backend looping on `PostgreSQL is unavailable`.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/adventurelog/compose.yml <<'EOF'
# AdventureLog · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .. https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/docker.md
#   variables ....... https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/.env.example
#
# Three services, and the names are load bearing: PUBLIC_SERVER_URL defaults to
# http://server:8000 and PGHOST is db. Two host ports, because the browser talks
# to both containers: the Django backend answers /media, /admin, /static and
# /accounts, the frontend answers the rest, the split upstream's Caddy guide
# documents. PostGIS is required and postgis/postgis publishes linux/amd64 only.
# Digests read from the registries on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgis/postgis:16-3.5@sha256:7d7925e334fceb6079c0a5d150e925f192cde2cf1dd78767ca843e2996d39829
    platform: linux/amd64
    container_name: adventurelog-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/adventurelog/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U adventurelog -d adventurelog"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  server:
    image: ghcr.io/seanmorley15/adventurelog-backend:v0.12.1@sha256:7c759efab1476841f7319776666e527bedd481cd71dbb08e51aaa5959f2a28eb
    container_name: adventurelog-backend
    restart: unless-stopped
    env_file: /srv/adventurelog/.env
    volumes:
      # Photographs, and the country flags the first boot downloads.
      - /srv/adventurelog/media:/code/media
    ports:
      # Loopback only: Caddy sends /media, /admin, /static and /accounts here.
      - "127.0.0.1:8268:80"
    depends_on:
      db:
        condition: service_healthy

  web:
    image: ghcr.io/seanmorley15/adventurelog-frontend:v0.12.1@sha256:edd79220f0def1dbea5b5d56636621f6cfdb454db9c00a8ce436a8ab489c5e99
    container_name: adventurelog-frontend
    restart: unless-stopped
    env_file: /srv/adventurelog/.env
    ports:
      # Loopback only: Caddy sends everything else here.
      - "127.0.0.1:8168:3000"
    depends_on:
      - server
EOF
cd /srv/adventurelog && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: `env file /srv/adventurelog/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal,
so run `rm /srv/adventurelog/compose.yml` and paste again in one go. A message about
`POSTGRES_DB` being unset means you are not in /srv/adventurelog, which is where compose reads
.env for those substitutions.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-adventurelog
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# AdventureLog · the Caddy site block for this service. Authored by
# caniselfhostit from
# https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/caddy.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# ORIGIN, PUBLIC_URL, FRONTEND_URL and CSRF_TRUSTED_ORIGINS in .env, so all five
# stay the same string or the login form answers 403.
#
# One hostname, two upstreams, the split upstream's Caddy guide documents: the
# Django backend answers /media, /admin, /static and /accounts, the frontend
# answers everything else. Send it all to the frontend and every photograph
# goes missing.

<DOMAIN> {
	# The app is a JavaScript bundle and the API answers JSON.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: the map draws tiles from basemaps.cartocdn.com
	# and the flags come from flagcdn.com, so one written untested breaks maps.

	# 8268 and 8168 are loopback ports compose publishes, closed in the firewall.
	@backend path /media* /admin* /static* /accounts*
	reverse_proxy @backend 127.0.0.1:8268

	reverse_proxy 127.0.0.1:8168
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-adventurelog /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is a `<DOMAIN>` you replaced in the site line but
not in the comment above it, which is harmless, or one you replaced nowhere, which is not. Caddy
issues and renews the certificate itself, and it sets the `X-Forwarded-Proto` header Django reads
to decide the request arrived over https.

## 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`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8168`, `8268` or `5432`.

If you do not: delete anything for those three with `sudo ufw delete allow 8168`. Both
application ports are bound to 127.0.0.1 by the compose file and 5432 is never published at all,
so the database has no host port a firewall rule could apply to. 80/tcp is there to redirect to
HTTPS and to answer the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which
Caddy offers by default. `Status: inactive` is a different problem: Prompt Zero left this
firewall enabled, so something has turned it off since, and `sudo ufw enable` puts it back before
you go any further.

## 7. Start and verify

The backend waits for PostgreSQL, runs its Django migrations, creates the `admin` account from
the three `DJANGO_ADMIN_` values, then downloads the world country and region dataset and a flag
image for every country before it serves anything. On a fresh box that last part takes minutes
with nothing to look at, which is why the loop below is patient.

```bash
cd /srv/adventurelog
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/ | grep -o '<title>[^<]*</title>'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/admin/login/
curl -sS http://127.0.0.1:8268/auth/is-registration-disabled/
docker compose exec -T db psql -U adventurelog -d adventurelog -tAc "SELECT count(*) FROM worldtravel_country;"
```

You should see, in order: the loop reaching `200`, then `<title>AdventureLog</title>`, then `200`
from the Django admin login page, then a small JSON object containing `"is_disabled":true`, then
a number of at least `195`.

If you do not: take them one at a time. If the loop never reaches `200`, run
`docker compose logs --tail 20 db` first, because a database that never reports healthy is step 2
done wrong, then `docker compose logs --tail 40 server`. A log ending in exit code 137 is the OOM
killer stopping the world-data import, and the fix is a bigger box, not a retry. A `502` means
Caddy is reaching nothing on 8168. The `200` from `/admin/login/` is the one worth understanding:
it proves Caddy is routing the four backend paths to 8268, and a `404` in its place means the
`@backend` matcher in step 5 did not land, which is the failure that shows up later as an album
with no photographs in it. `"is_disabled":false` means `DISABLE_REGISTRATION` did not reach the
container, and you should stop and fix that before this hostname is public for another minute. A
count under 195 means the import did not finish.

The first screen at https://<DOMAIN> is the AdventureLog landing page with a `Login` button, and
https://<DOMAIN>/login shows a form with `Username` and `Password` boxes and no sign-up link. Log
in there as `admin` with the password you read in step 3, and confirm the dashboard loads. Three
green containers in `docker compose ps` is not the same thing as a working install.

## 8. First backup and restore

Two artifacts. The database holds every trip, location and visit. The config archive holds your
photographs plus the three files that rebuild the service around them.

```bash
cd /srv/adventurelog
docker compose exec -T db pg_dump -U adventurelog -d adventurelog | gzip > /srv/adventurelog/backups/adventurelog-db-$(date +%F).sql.gz
sudo tar -czf /srv/adventurelog/backups/adventurelog-config-$(date +%F).tar.gz -C /srv/adventurelog compose.yml .env media -C /etc/caddy Caddyfile
ls -lh /srv/adventurelog/backups/
```

You should see: two files. The database dump is small on a fresh install, a few hundred kilobytes
once the world data is in it. The config archive is tens of megabytes, because the country flag
images live in `media`. Nothing goes offline: `pg_dump` snapshots a running database
consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway. Run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/adventurelog
scp vps:/srv/adventurelog/backups/* ~/backups/adventurelog/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/adventurelog/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty install:

```bash
cd /srv/adventurelog
docker compose down
sudo rm -rf /srv/adventurelog/postgres
sudo install -d -m 700 /srv/adventurelog/postgres
docker compose up -d db
sleep 30
gunzip -c /srv/adventurelog/backups/adventurelog-db-$(date +%F).sql.gz | docker compose exec -T db psql -U adventurelog -d adventurelog
docker compose up -d
sleep 60
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `200` from the last command.

If you do not: `role "adventurelog" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand what the two archives
are for before you skip this: your photographs are files inside `media` and everything that says
which trip they belong to is rows in the database, so restoring one without the other gives you
a gallery with no trips or a trip with no pictures.

## 9. Updating later

New versions are listed at https://github.com/seanmorley15/AdventureLog/releases. Migrations run
at start-up and upstream asks you to back up before updating, so take both artifacts first, then
edit the two `image:` lines in /srv/adventurelog/compose.yml to the new tags and their digests.

```bash
cd /srv/adventurelog
docker compose pull
docker compose up -d
docker compose logs --tail 30 server
```

You should see: migration output, then the server starting, and no repeating restart.

If you do not: put the old tags and digests back and run the same three commands. Then re-run all
five checks from step 7 before you call the update done. This project is on 0.x version numbers
and ships a few releases a year, so read the release notes before you move: a minor bump here is
not always a small one.

## 10. What will probably go wrong

The first `docker compose up -d` looks like a hang, and I nearly restarted it. It is not: before
the backend serves one request it downloads a world dataset of countries, regions and cities,
then fetches a flag image for every country in turn, and on a small VPS that took me several
minutes with nothing on screen but a container that would not answer. Let step 7's loop run all
sixty attempts before touching anything. The identical-looking failure is worth checking
afterwards: if `docker compose logs server` ends with exit code 137, the import was killed for
memory, and the fix is a bigger box rather than a retry.

## 11. Out of scope

- Do not set `GOOGLE_MAPS_API_KEY`. Place search falls back to OpenStreetMap's Nominatim without
  it, and the alternative is a Google Cloud project with billing attached.
- Do not configure SMTP or set `EMAIL_BACKEND`. Email verification ships off, the one account
  here is already verified, and mail from a fresh VPS is its own week of work.
- Do not enable social login, the Strava integration or the Immich integration. Each means
  registering an application somewhere else, and none is needed to log a trip.
- Do not remove `DISABLE_REGISTRATION`. On a public hostname that reopens the sign-up form.
````

## Local install prompt (your own computer, no server)

````text
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 AdventureLog 0.12.1, with the PostGIS database it stores trips in, under
~/selfhost/adventurelog, answering at http://localhost:8168.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
AdventureLog answers only at http://localhost:8168, this computer and nowhere else, so the phone
that took the photographs cannot upload to it and nobody they travel with can open the trip.
Every picture arrives by being copied onto this machine and dragged into a browser.

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"; uname -m; 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. This needs
2048 MB of RAM available and 10 GB free on the home disk: the first boot imports a world
geography dataset and upstream asks for 2 GB while it runs. PostGIS is required and its image is
linux/amd64 only, so an Apple Silicon Mac translates it under Docker Desktop, and a Linux machine
printing `aarch64` has no translation, so stop there. If RAM is under 2048 MB or disk under
10 GB, 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/adventurelog/media ~/selfhost/adventurelog/backups
ls -la ~/selfhost/adventurelog
```

Assert: `ls -la` shows `media` and `backups`. The backend container runs as root and writes
photographs and flags into `media`, so on Linux they end up root-owned but world-readable, which
lets step 8 archive them without sudo. On macOS and Windows Docker Desktop maps ownership itself.
The database lives in a volume Docker manages.

## 4. Secrets

Three secrets, all generated here: the PostgreSQL password, Django's `SECRET_KEY`, and the
password for the `admin` account the backend creates on first boot. Print none of them and keep
all three out of your summary and any log line.

```bash
umask 077
cat > ~/selfhost/adventurelog/.env <<EOF
PUBLIC_SERVER_URL=http://server:8000
ORIGIN=http://localhost:8168
BODY_SIZE_LIMIT=Infinity
PGHOST=db
POSTGRES_DB=adventurelog
POSTGRES_USER=adventurelog
POSTGRES_PASSWORD=$(openssl rand -hex 32)
SECRET_KEY=$(openssl rand -hex 48)
DJANGO_ADMIN_USERNAME=admin
DJANGO_ADMIN_PASSWORD=$(openssl rand -hex 24)
DJANGO_ADMIN_EMAIL=admin@localhost
PUBLIC_URL=http://localhost:8268
FRONTEND_URL=http://localhost:8168
CSRF_TRUSTED_ORIGINS=http://localhost:8168,http://localhost:8268
DEBUG=False
DISABLE_REGISTRATION=True
ENABLE_RATE_LIMITS=True
EOF
chmod 600 ~/selfhost/adventurelog/.env
umask 022
ls -l ~/selfhost/adventurelog/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so these lines run the
same everywhere. `PUBLIC_URL` is where the browser fetches photographs, which is why it names
8268, and `CSRF_TRUSTED_ORIGINS` lists both ports because the browser treats them as two
origins. `DEBUG` defaults to true in the image, so False matters even on a laptop. On Windows
those mode bits are advisory: the user's own account is the real boundary.

## 5. compose.yml

```bash
cat > ~/selfhost/adventurelog/compose.yml <<'EOF'
# AdventureLog · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install .. https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/docker.md
#   variables ....... https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/.env.example
#
# Three services, every path relative to ~/selfhost/adventurelog/ so one file
# works on macOS, Linux and Windows. The names are load bearing:
# PUBLIC_SERVER_URL defaults to http://server:8000 and PGHOST is db. Two host
# ports: the backend serves photographs on 8268, the frontend the app on 8168.
# The database is a named volume because PostgreSQL chowns its data directory to
# a uid a home bind mount cannot grant on Windows. PostGIS is linux/amd64 only.
# Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgis/postgis:16-3.5@sha256:7d7925e334fceb6079c0a5d150e925f192cde2cf1dd78767ca843e2996d39829
    platform: linux/amd64
    container_name: adventurelog-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - adventurelog-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U adventurelog -d adventurelog"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  server:
    image: ghcr.io/seanmorley15/adventurelog-backend:v0.12.1@sha256:7c759efab1476841f7319776666e527bedd481cd71dbb08e51aaa5959f2a28eb
    container_name: adventurelog-backend
    restart: unless-stopped
    env_file: ./.env
    volumes:
      - ./media:/code/media
    ports:
      # Loopback only: nothing else on the wifi reaches 8268.
      - "127.0.0.1:8268:80"
    depends_on:
      db:
        condition: service_healthy

  web:
    image: ghcr.io/seanmorley15/adventurelog-frontend:v0.12.1@sha256:edd79220f0def1dbea5b5d56636621f6cfdb454db9c00a8ce436a8ab489c5e99
    container_name: adventurelog-frontend
    restart: unless-stopped
    env_file: ./.env
    ports:
      # Loopback only: nothing else on the wifi reaches 8168.
      - "127.0.0.1:8168:3000"
    depends_on:
      - server

volumes:
  adventurelog-pgdata:
EOF
cd ~/selfhost/adventurelog && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`: three services, two ports, one named volume.

## 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 nothing here has; browsers treat
http://localhost as a secure context anyway, so pages needing crypto still work. Nothing is
published beyond loopback: 8168 and 8268 bind to 127.0.0.1, not the user's phone, not a laptop on
the wifi, not the internet. Confirm it:

```bash
grep -c '"127.0.0.1:' ~/selfhost/adventurelog/compose.yml
```

Assert: that prints `2`, the frontend port and the backend port. PostgreSQL publishes no host
port, so 5432 never appears.

## 7. Start and verify

The backend waits for PostgreSQL, migrates, creates the `admin` account from the three
`DJANGO_ADMIN_` values, then downloads the world country and region dataset and a flag for every
country before it serves anything. Minutes on a first boot, so the loop below is patient.

```bash
cd ~/selfhost/adventurelog
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8168/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8168/ | grep -o '<title>[^<]*</title>'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8268/admin/login/
curl -sS http://localhost:8268/auth/is-registration-disabled/
docker compose exec -T db psql -U adventurelog -d adventurelog -tAc "SELECT count(*) FROM worldtravel_country;"
```

Assert all five, and print what you received for each: the loop ends on `200`; the title line
prints `<title>AdventureLog</title>`; the admin login page answers `200`, proving the backend
port serves; the registration endpoint prints `"is_disabled":true`, the security assert; the
count is at least `195`, so the world-data import finished rather than being killed. If any
misses, stop, run `docker compose logs --tail 40 server` and `docker compose logs --tail 20 db`,
and name the cause: a database never reporting healthy points at step 4, exit code 137 is memory
running out during the import, and on `port is already allocated` find what holds it with
`lsof -nP -iTCP:8168 -sTCP:LISTEN`. A running container is not success.

The first screen at http://localhost:8168 is the AdventureLog landing page with a `Login` button,
and http://localhost:8168/login shows `Username` and `Password` boxes and no sign-up link.

STOP: tell the user to read their admin password with
`grep DJANGO_ADMIN_PASSWORD ~/selfhost/adventurelog/.env`, put it in their password manager, sign
in at http://localhost:8168/login as `admin`, confirm the dashboard loads, and wait.
Do not continue until they confirm. It is the only credential here.

## 8. First backup and restore

Two artifacts: the database holds every trip, location and visit, the config archive the
photographs and the two files that rebuild the service.

```bash
cd ~/selfhost/adventurelog
docker compose exec -T db pg_dump -U adventurelog -d adventurelog | gzip > ~/selfhost/adventurelog/backups/adventurelog-db-$(date +%F).sql.gz
tar -C ~/selfhost/adventurelog -czf ~/selfhost/adventurelog/backups/adventurelog-config-$(date +%F).tar.gz compose.yml .env media
ls -lh ~/selfhost/adventurelog/backups/
```

Assert: both exist and both are non-empty. Print both sizes. The config archive is tens of
megabytes because the country flags are in it. Nothing is stopped: `pg_dump` snapshots a live
database consistently.

Both sit on the same disk as the data, which is not a backup: 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 both there with `cp`. In Git Bash a Windows drive is written `/d/Backups`.
Assert: the user confirms both filenames are there. If not, say plainly this install has no
backup.

To restore, in this order. `cd ~/selfhost/adventurelog` and untar the config archive there first,
so compose.yml, .env and the photographs are back before any container starts: PostgreSQL takes
`POSTGRES_PASSWORD` from .env the moment it initialises an empty volume. Then
`docker compose down -v`, the one place `-v` belongs because it drops the old volume on purpose,
`docker compose up -d db`, wait 30 seconds, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T db psql -U adventurelog -d adventurelog`, then `docker compose up -d`.
Open a trip and check its photographs draw.

## 9. Updating later

New versions are listed at https://github.com/seanmorley15/AdventureLog/releases. Migrations run
at start-up and upstream asks you to back up first, so take both artifacts, then edit the two
image lines in ~/selfhost/adventurelog/compose.yml to the new tags and digests:

```bash
cd ~/selfhost/adventurelog
docker compose pull
docker compose up -d
docker compose logs --tail 30 server
```

Watch that log until the migrations settle, then re-run step 7's five checks. This project is on
0.x numbers and ships a few releases a year: read the release notes first.

## 10. What will probably go wrong

I rebooted this machine, opened http://localhost:8168 to add a weekend's photographs, and got a
connection error that looked like a lost database. Docker Desktop had not started with
the session, so nothing was listening on either port. `restart: unless-stopped` acts
only once the Docker daemon is up. Turn on Docker Desktop's start-at-login setting, and after a
reboot run `cd ~/selfhost/adventurelog && docker compose up -d` before concluding anything is
broken. The second start is fast: the world-data import already ran.

## 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 rebind 8168 or 8268 to 0.0.0.0 for a phone on the wifi. That puts a login form with no
  TLS on every network the user joins.
- Do not set `GOOGLE_MAPS_API_KEY`, configure SMTP, or enable social login, Strava or Immich.
  Each means an account somewhere else, and none is needed to log a trip.
````

## docker-compose.yml

```yaml
# AdventureLog · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install .. https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/docker.md
#   variables ....... https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/.env.example
#
# Three services, and the names are load bearing: PUBLIC_SERVER_URL defaults to
# http://server:8000 and PGHOST is db. Two host ports, because the browser talks
# to both containers: the Django backend answers /media, /admin, /static and
# /accounts, the frontend answers the rest, the split upstream's Caddy guide
# documents. PostGIS is required and postgis/postgis publishes linux/amd64 only.
# Digests read from the registries on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgis/postgis:16-3.5@sha256:7d7925e334fceb6079c0a5d150e925f192cde2cf1dd78767ca843e2996d39829
    platform: linux/amd64
    container_name: adventurelog-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /srv/adventurelog/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U adventurelog -d adventurelog"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  server:
    image: ghcr.io/seanmorley15/adventurelog-backend:v0.12.1@sha256:7c759efab1476841f7319776666e527bedd481cd71dbb08e51aaa5959f2a28eb
    container_name: adventurelog-backend
    restart: unless-stopped
    env_file: /srv/adventurelog/.env
    volumes:
      # Photographs, and the country flags the first boot downloads.
      - /srv/adventurelog/media:/code/media
    ports:
      # Loopback only: Caddy sends /media, /admin, /static and /accounts here.
      - "127.0.0.1:8268:80"
    depends_on:
      db:
        condition: service_healthy

  web:
    image: ghcr.io/seanmorley15/adventurelog-frontend:v0.12.1@sha256:edd79220f0def1dbea5b5d56636621f6cfdb454db9c00a8ce436a8ab489c5e99
    container_name: adventurelog-frontend
    restart: unless-stopped
    env_file: /srv/adventurelog/.env
    ports:
      # Loopback only: Caddy sends everything else here.
      - "127.0.0.1:8168:3000"
    depends_on:
      - server
```

## compose.local.yml

```yaml
# AdventureLog · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install .. https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/docker.md
#   variables ....... https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/.env.example
#
# Three services, every path relative to ~/selfhost/adventurelog/ so one file
# works on macOS, Linux and Windows. The names are load bearing:
# PUBLIC_SERVER_URL defaults to http://server:8000 and PGHOST is db. Two host
# ports: the backend serves photographs on 8268, the frontend the app on 8168.
# The database is a named volume because PostgreSQL chowns its data directory to
# a uid a home bind mount cannot grant on Windows. PostGIS is linux/amd64 only.
# Digests read 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  db:
    image: postgis/postgis:16-3.5@sha256:7d7925e334fceb6079c0a5d150e925f192cde2cf1dd78767ca843e2996d39829
    platform: linux/amd64
    container_name: adventurelog-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - adventurelog-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U adventurelog -d adventurelog"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other containers.

  server:
    image: ghcr.io/seanmorley15/adventurelog-backend:v0.12.1@sha256:7c759efab1476841f7319776666e527bedd481cd71dbb08e51aaa5959f2a28eb
    container_name: adventurelog-backend
    restart: unless-stopped
    env_file: ./.env
    volumes:
      - ./media:/code/media
    ports:
      # Loopback only: nothing else on the wifi reaches 8268.
      - "127.0.0.1:8268:80"
    depends_on:
      db:
        condition: service_healthy

  web:
    image: ghcr.io/seanmorley15/adventurelog-frontend:v0.12.1@sha256:edd79220f0def1dbea5b5d56636621f6cfdb454db9c00a8ce436a8ab489c5e99
    container_name: adventurelog-frontend
    restart: unless-stopped
    env_file: ./.env
    ports:
      # Loopback only: nothing else on the wifi reaches 8168.
      - "127.0.0.1:8168:3000"
    depends_on:
      - server

volumes:
  adventurelog-pgdata:
```

## Caddyfile

```text
# AdventureLog · the Caddy site block for this service. Authored by
# caniselfhostit from
# https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/caddy.md
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. That hostname is also
# ORIGIN, PUBLIC_URL, FRONTEND_URL and CSRF_TRUSTED_ORIGINS in .env, so all five
# stay the same string or the login form answers 403.
#
# One hostname, two upstreams, the split upstream's Caddy guide documents: the
# Django backend answers /media, /admin, /static and /accounts, the frontend
# answers everything else. Send it all to the frontend and every photograph
# goes missing.

<DOMAIN> {
	# The app is a JavaScript bundle and the API answers JSON.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
	# No Content-Security-Policy: the map draws tiles from basemaps.cartocdn.com
	# and the flags come from flagcdn.com, so one written untested breaks maps.

	# 8268 and 8168 are loopback ports compose publishes, closed in the firewall.
	@backend path /media* /admin* /static* /accounts*
	reverse_proxy @backend 127.0.0.1:8268

	reverse_proxy 127.0.0.1:8168
}
```

## install.sh

```bash
#!/usr/bin/env bash
# AdventureLog · 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=trips.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/docker.md
#   https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/.env.example
#   https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/install/caddy.md
#   https://github.com/seanmorley15/AdventureLog/blob/v0.12.1/documentation/docs/configuration/disable_registration.md
#
# Three secrets are generated here, on this machine: the PostgreSQL password,
# Django's SECRET_KEY, and the password for the admin account the backend
# creates on first boot. All three go into /srv/adventurelog/.env with mode 600
# and none is ever printed.
#
# DOMAIN_HOST becomes ORIGIN, PUBLIC_URL, FRONTEND_URL and CSRF_TRUSTED_ORIGINS
# in that one file, and it fronts every image URL, so changing it later is an
# edit in four places.
#
# This install is amd64 only. PostGIS is required by AdventureLog and the
# postgis/postgis image publishes linux/amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/adventurelog}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

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. trips.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"

arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "this server is ${arch}; PostGIS publishes no ARM image, so this install stops here"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; the first boot imports world geography data and wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

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 ----------------------------------------------------
#
# media stays root-owned: the backend container runs as root and writes the
# photographs and the country flags into it. postgres stays root-owned because
# the database image chowns its own data directory on first start.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 "$APP_DIR/media"
sudo install -d -m 700 "$APP_DIR/postgres"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex rather than base64: one of them travels inside a database connection
# string. Read them later with
#   sudo grep -E 'POSTGRES_PASSWORD|SECRET_KEY|DJANGO_ADMIN_PASSWORD' /srv/adventurelog/.env
#
# DEBUG defaults to true in the image, DISABLE_REGISTRATION closes a sign-up
# form that is otherwise open on a public hostname, and ENABLE_RATE_LIMITS
# defaults to false and switches on upstream's throttle for failed logins.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		PUBLIC_SERVER_URL=http://server:8000
		ORIGIN=https://${DOMAIN_HOST}
		BODY_SIZE_LIMIT=Infinity
		PGHOST=db
		POSTGRES_DB=adventurelog
		POSTGRES_USER=adventurelog
		POSTGRES_PASSWORD=$(openssl rand -hex 32)
		SECRET_KEY=$(openssl rand -hex 48)
		DJANGO_ADMIN_USERNAME=admin
		DJANGO_ADMIN_PASSWORD=$(openssl rand -hex 24)
		DJANGO_ADMIN_EMAIL=admin@${DOMAIN_HOST}
		PUBLIC_URL=https://${DOMAIN_HOST}
		FRONTEND_URL=https://${DOMAIN_HOST}
		CSRF_TRUSTED_ORIGINS=https://${DOMAIN_HOST}
		DEBUG=False
		DISABLE_REGISTRATION=True
		ENABLE_RATE_LIMITS=True
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------
#
# One hostname, two upstreams: the Django backend answers /media, /admin,
# /static and /accounts, the frontend answers everything else.

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-adventurelog"
	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 none of 8168, 8268 or 5432 is 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; 8168, 8268 and 5432 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The backend migrates, creates the admin account from the DJANGO_ADMIN_ values,
# then downloads the world country and region dataset and a flag image for every
# country before it serves anything. That is minutes on a first boot.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/ (the world-data import runs first)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the site answered ${code:-nothing}. Check: docker compose logs --tail 40 server (exit code 137 means it ran out of memory)"

curl -sS "https://${DOMAIN_HOST}/" | grep -q '<title>AdventureLog</title>' \
	|| die "the root URL did not serve the AdventureLog page. Check that Caddy is reaching 127.0.0.1:8168"

# Proves Caddy routes the four backend paths to 8268 rather than sending
# everything to the frontend. Get this wrong and every photograph is missing.
admin_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/admin/login/" || true)"
[ "$admin_code" = "200" ] || die "https://${DOMAIN_HOST}/admin/login/ returned ${admin_code}, not 200. The @backend matcher in the Caddy block did not land."

# The security assert: registration must be closed on a public hostname.
curl -sS "http://127.0.0.1:8268/auth/is-registration-disabled/" | grep -q '"is_disabled":true' \
	|| die "registration is still open. Stop: DISABLE_REGISTRATION did not reach the container."

countries="$(docker compose exec -T db psql -U adventurelog -d adventurelog -tAc 'SELECT count(*) FROM worldtravel_country;' | tr -dc '0-9')"
[ "${countries:-0}" -ge 195 ] || die "only ${countries:-0} countries imported. The world-data import did not finish; check docker compose logs --tail 40 server"

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T db pg_dump -U adventurelog -d adventurelog | gzip > "$APP_DIR/backups/adventurelog-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/adventurelog-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env media -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/adventurelog-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	AdventureLog is answering at https://${DOMAIN_HOST}

	  1. Sign in at https://${DOMAIN_HOST}/login as the user admin. Read the
	     password with
	       sudo grep DJANGO_ADMIN_PASSWORD $APP_DIR/.env
	     and put it in your password manager. It was not printed here, and no
	     mail server on this box can send you a reset link.
	  2. Registration is closed, and this script asserted it before finishing.
	     To add a second traveller, create the account yourself in the Django
	     admin at https://${DOMAIN_HOST}/admin/ rather than reopening sign-up.
	  3. Your maps are drawn from tiles fetched by your browser from
	     basemaps.cartocdn.com, and the country flags came from flagcdn.com
	     during the first boot. Nothing about your trips is sent to either, but
	     the requests happen and they are not requests you are hosting.
	  4. First backup written to $APP_DIR/backups: a database dump and a config
	     archive holding compose.yml, .env, media and the Caddy config. They are
	     on the same disk as the data, which is not a backup. Copy them
	     somewhere else tonight, and copy both: the photographs are in media and
	     everything saying which trip they belong to is in the database.

DONE
```

The page this mirrors: https://caniselfhostit.com/self-host/wanderlog-pro/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
