# Can I self-host Zight?

**YES** — it's called Zipline. ONE EVENING setup · ~1.3 hours to running · 2 GB RAM minimum · $9.95/mo you stop paying ($119.40/yr on the Create plan).

Zipline authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/zight/

## 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 Zipline 4.6.5 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.
Tell them why it matters while you ask: `<DOMAIN>` is the front of every share link this server
hands out, so a screenshot they paste into a ticket today carries that hostname for as long as
the link matters. Its A record must already point at this server.

Zipline needs 2048 MB of RAM available and 10 GB free on /srv. That floor is the thumbnail
workers rather than the web server: the image ships ffmpeg and renders video thumbnails on four
threads. Both images publish amd64 and arm64. 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. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve.

## 2. Layout

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

Assert: `ls -la` shows `uploads` and `backups` owned by the login user, and `postgres` at mode
`700` owned by root. Leave that one alone: the PostgreSQL image chowns its own data directory on
first start, and one already chowned elsewhere makes it refuse to initialise. The Zipline
container runs as root, so files it writes under `uploads` belong to root.

## 3. Secrets

Two secrets, both generated here on the server: the PostgreSQL password and `CORE_SECRET`, which
signs session cookies. Do not print either, do not repeat them in your summary, and do not put
them in any log line. Hex rather than base64 for both: one travels inside a connection string,
and upstream refuses to start on a secret under 32 characters, which 32 hex bytes clears.

```bash
umask 077
cat > /srv/zipline/.env <<EOF
CORE_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/zipline/.env
umask 022
ls -l /srv/zipline/.env
```

Assert: the file exists with mode `-rw-------`. Tell the user that changing `CORE_SECRET` later
logs every session out, including their own, so it is a value to leave alone once the install
works. They never have to read it: the only credential they type is the password they choose in
step 7.

## 4. compose.yml

```bash
cat > /srv/zipline/compose.yml <<'EOF'
# Zipline · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://zipline.diced.sh/docs/get-started/docker
#   variable reference . https://zipline.diced.sh/docs/config
#   core variables ..... https://zipline.diced.sh/docs/config/core
#   hardening guide .... https://zipline.diced.sh/docs/guides/hardening
#   reverse proxy ...... https://zipline.diced.sh/docs/guides/reverse-proxy
#
# Two services: Zipline and the PostgreSQL that holds accounts, tokens, short
# links and one row per uploaded file. The files themselves sit on disk under
# /srv/zipline/uploads, so a restore needs both halves or you get an index of
# things that are not there. Upstream's own compose file runs postgres 16, so
# this one stays on that major and pins a patch of it. Digests were read from
# the registries on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: zipline-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: zipline
      POSTGRES_USER: zipline
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/zipline/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zipline -d zipline"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  zipline:
    image: ghcr.io/diced/zipline:4.6.5@sha256:bfd5b0f7b5b8b3ed058a81667c78a14a7f997115d8433bec273620ec81be51d4
    container_name: zipline
    restart: unless-stopped
    env_file: /srv/zipline/.env
    environment:
      DATABASE_URL: postgres://zipline:${DB_PASSWORD}@postgres:5432/zipline
      # Caddy terminates TLS and speaks plain http here. Upstream asks for both
      # of these behind a proxy: without the first, every request looks like it
      # came from 127.0.0.1; without the second, the links handed back start
      # with http:// on a site only reachable over https.
      CORE_TRUST_PROXY: "true"
      CORE_RETURN_HTTPS_URLS: "true"
      # Registration is off upstream by default and this pins it off, so the
      # only account ever made is the first one, through the setup wizard.
      FEATURES_USER_REGISTRATION: "false"
      # Read the type off the file instead of believing the uploader, and serve
      # the two that execute in a browser as a download rather than inline.
      FILES_ASSUME_MIMETYPES: "true"
      FILES_DISABLED_TYPES: text/html,application/javascript
      FILES_DISABLED_TYPES_DEFAULT: application/octet-stream
    volumes:
      - /srv/zipline/uploads:/zipline/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8156.
      - "127.0.0.1:8156:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/zipline && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Two services, one published port. Uploaded files live on the
host under /srv/zipline/uploads; everything else about them, the owner, the short code, the view
count, is a row in PostgreSQL. That is why step 8 takes two artifacts rather than one. The six
settings under `DATABASE_URL` are pinned here rather than left to the dashboard: every start
re-applies them, so a change made in the web settings will not survive a restart.

## 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-zipline
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Zipline · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://zipline.diced.sh/docs/guides/reverse-proxy,
# https://caddyserver.com/docs/caddyfile/directives/request_body 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 hostname is
# the front of every share link this server hands out, so it is the value here
# worth choosing once and keeping.

<DOMAIN> {
	# Uploads are mostly already-compressed image and video formats, so there
	# is nothing to gain by compressing them again. These four headers are the
	# part worth having: a file host serves other people's bytes to strangers.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Zipline's own default file size limit is 100 MB and it cuts anything
	# larger into 25 MB chunks, so 128 MB of request body covers both with room
	# to spare. Caddy answers 413 above this. Raise Zipline's limit and this
	# number together or the proxy will refuse what the app would have taken.
	request_body {
		max_size 128MB
	}

	# 8156 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8156
}
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-zipline, reload, and report what it objected to. Caddy gets the
certificate on the first request and renews it alone, so nothing needs scheduling. It
terminates TLS and speaks plain http to the container, which is why `CORE_TRUST_PROXY` and
`CORE_RETURN_HTTPS_URLS` are both true in compose.yml: without them Zipline treats every request
as coming from 127.0.0.1 and hands back `http://` links for a site that only answers on https.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured 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 redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, 443/udp is
HTTP/3. 8156 stays closed because compose binds it to 127.0.0.1, and 5432 stays closed because
compose never publishes it: the database has no host port a rule could apply to. Assert:
`ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule for
8156 or 5432.

## 7. Start and verify

Zipline runs its own database migrations on the way up, so the first start is slower than the
ones after it.

```bash
cd /srv/zipline
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>/api/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/healthcheck
curl -sS https://<DOMAIN>/api/setup
```

Assert, all three. The loop ends printing `200`. The health call prints `{"pass":true}`, which
upstream documents as the server and the database both answering. The setup call prints
`{"firstSetup":true}`, which is this instance saying it has no accounts yet. Print what you
received for each. If any of the three misses, stop, run
`docker compose logs --tail 40 zipline` and `docker compose logs --tail 20 postgres`, and say
which earlier step is the likely cause: a database that never reports healthy points at step 2,
and a `502` where a `200` was expected means the container is still migrating. A running
container is not success.

The first screen is at https://<DOMAIN>/auth/setup and its heading reads `Welcome to Zipline!`,
above a stepper with a `Username` and a `Password` field.

STOP: tell the user to open https://<DOMAIN>/auth/setup, create the first account, then, still
logged in, open Settings from the user menu at the top right, scroll to `Generate Uploaders`,
and download the `ShareX` config on Windows or the `Flameshot` script on Linux and macOS. That
download is the point of this install: it aims the screenshot tool they already have at this
server. Wait. Do not continue until they confirm both.

Once they confirm, prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/setup
curl -sS https://<DOMAIN>/api/server/public | grep -oE '"(userRegistration|firstSetup)":[a-z]+'
```

Assert: the first prints `403`, upstream's answer once the setup wizard has been claimed, so no
second superadmin can be made through it. The second prints `"userRegistration":false` and
`"firstSetup":false`. Both asserts must pass before you report success.

## 8. First backup and restore

Two artifacts, and neither one is worth anything alone. The database holds the accounts, the
tokens, the short links and one row per file. The archive holds the files themselves plus the
three configuration files that rebuild the service around them.

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

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. The second archive is kilobytes today and
every file they ever uploaded a year from now, so the nightly job they write later wants `rsync`
on uploads, not a growing tarball.

A backup on the same disk is not a backup, so run this from the user's machine:

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

To restore: `docker compose down`, `sudo rm -rf /srv/zipline/postgres /srv/zipline/uploads`,
recreate both directories as in step 2, untar the files archive into /srv/zipline,
`docker compose up -d postgres`, wait about 30 seconds for it to report healthy, then pipe
`gunzip -c` on the `.sql.gz` into `docker compose exec -T postgres psql -U zipline -d zipline`,
then `docker compose up -d`. Open one old link and check the file comes back. Tell the user the
stakes plainly: every link they have pasted into a ticket or a chat points at this box, and a
database restored without its uploads directory answers all of them with a missing file.

## 9. Updating later

New versions are listed at https://github.com/diced/zipline/releases. Take both backups first,
then edit the image line in /srv/zipline/compose.yml to the new tag and its digest:

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

Zipline migrates its own database on the way up, so watch that log until it settles, then re-run
step 7's health check before calling the update done.

## 10. What will probably go wrong

You will open https://<DOMAIN> in a browser, see a screen reading `404` and `Page not found`,
and conclude Caddy is pointed at nothing. I did, and I spent ten minutes re-reading the site
block. It was fine: Zipline has no home page, the root path falls through to its own not-found
screen, and the only thing at the bare hostname is a `Go home` button back to the login page.
The address that tells the truth on a fresh install is https://<DOMAIN>/auth/setup. Check
that, not the root.

## 11. Out of scope

- Do not configure SMTP. Zipline sends no mail at all, so there is nothing for it to do.
- Do not register an OAuth application with Discord, GitHub, Google or an OIDC provider. A
  password login with registration closed is the whole account model this install commits to.
- Do not switch the datasource to S3. Local storage under /srv/zipline/uploads is the choice
  here, and it is the one step 8 knows how to back up.
- Do not raise `FILES_MAX_FILE_SIZE` without raising `max_size` in the Caddy block by the same
  amount. Raise one alone and the other returns `413` on uploads the app would have taken.
````

## 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 Zipline 4.6.5 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. `<DOMAIN>` is the front of every share link this server hands out. A
screenshot you paste into a ticket today carries that hostname for as long as the link is worth
anything, so pick the name you intend to keep.

## 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` 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,
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. If the RAM number is
under 2048, the thing that will kill you is not the web server, it is the thumbnail workers: the
image ships ffmpeg and renders video thumbnails on four threads, and the OOM killer arrives
during the first video rather than during the install.

## 2. Layout

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

You should see: `uploads` and `backups` owned by you, and `postgres` at mode `drwx------` owned
by root.

If you do not: leave `postgres` owned by root on purpose. 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. `uploads` is yours, but the Zipline container runs as root, so the files
it writes in there will belong to root. That is why step 8 uses `sudo tar`.

## 3. Secrets

Two secrets: the PostgreSQL password and `CORE_SECRET`, which signs session cookies. Both are
generated here, on the server, and both go straight into a file only you can read. Hex rather
than base64 for both: one travels inside a connection string, and upstream refuses to start on a
secret shorter than 32 characters, which 32 hex bytes clears.

```bash
umask 077
cat > /srv/zipline/.env <<EOF
CORE_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/zipline/.env
umask 022
ls -l /srv/zipline/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/zipline/.env` and carry on.
If the file already existed from an earlier attempt, this block has now overwritten both secrets,
which is fine before the database exists and a problem afterwards: PostgreSQL keeps the password
it was created with, so a changed `DB_PASSWORD` on an existing volume shows up as an
authentication failure in the Zipline log rather than as anything about passwords.

Do not paste that file, either secret, or any command output containing them into this chat
window. You never need to read either value: the only credential you will type is the password
you choose in step 7, in a browser. Changing `CORE_SECRET` later logs every session out,
including your own, so leave it alone once this works.

## 4. compose.yml

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

```bash
cat > /srv/zipline/compose.yml <<'EOF'
# Zipline · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://zipline.diced.sh/docs/get-started/docker
#   variable reference . https://zipline.diced.sh/docs/config
#   core variables ..... https://zipline.diced.sh/docs/config/core
#   hardening guide .... https://zipline.diced.sh/docs/guides/hardening
#   reverse proxy ...... https://zipline.diced.sh/docs/guides/reverse-proxy
#
# Two services: Zipline and the PostgreSQL that holds accounts, tokens, short
# links and one row per uploaded file. The files themselves sit on disk under
# /srv/zipline/uploads, so a restore needs both halves or you get an index of
# things that are not there. Upstream's own compose file runs postgres 16, so
# this one stays on that major and pins a patch of it. Digests were read from
# the registries on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: zipline-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: zipline
      POSTGRES_USER: zipline
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/zipline/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zipline -d zipline"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  zipline:
    image: ghcr.io/diced/zipline:4.6.5@sha256:bfd5b0f7b5b8b3ed058a81667c78a14a7f997115d8433bec273620ec81be51d4
    container_name: zipline
    restart: unless-stopped
    env_file: /srv/zipline/.env
    environment:
      DATABASE_URL: postgres://zipline:${DB_PASSWORD}@postgres:5432/zipline
      # Caddy terminates TLS and speaks plain http here. Upstream asks for both
      # of these behind a proxy: without the first, every request looks like it
      # came from 127.0.0.1; without the second, the links handed back start
      # with http:// on a site only reachable over https.
      CORE_TRUST_PROXY: "true"
      CORE_RETURN_HTTPS_URLS: "true"
      # Registration is off upstream by default and this pins it off, so the
      # only account ever made is the first one, through the setup wizard.
      FEATURES_USER_REGISTRATION: "false"
      # Read the type off the file instead of believing the uploader, and serve
      # the two that execute in a browser as a download rather than inline.
      FILES_ASSUME_MIMETYPES: "true"
      FILES_DISABLED_TYPES: text/html,application/javascript
      FILES_DISABLED_TYPES_DEFAULT: application/octet-stream
    volumes:
      - /srv/zipline/uploads:/zipline/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8156.
      - "127.0.0.1:8156:3000"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/zipline && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/zipline/.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:
run `rm /srv/zipline/compose.yml` and paste again in one go. Two services, one published port.
The uploaded files live on the host under /srv/zipline/uploads and everything else about them,
the owner, the short code, the view count, is a row in PostgreSQL, which is why step 8 takes two
artifacts rather than one. One thing to know before you go looking for these in the web
interface: the six settings under `DATABASE_URL` are pinned in this file rather than left to the
dashboard, and every start re-applies them, so changing one of those six in the web settings
will not survive a restart. Change them here and run `docker compose up -d`.

## 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-zipline
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Zipline · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://zipline.diced.sh/docs/guides/reverse-proxy,
# https://caddyserver.com/docs/caddyfile/directives/request_body 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 hostname is
# the front of every share link this server hands out, so it is the value here
# worth choosing once and keeping.

<DOMAIN> {
	# Uploads are mostly already-compressed image and video formats, so there
	# is nothing to gain by compressing them again. These four headers are the
	# part worth having: a file host serves other people's bytes to strangers.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Zipline's own default file size limit is 100 MB and it cuts anything
	# larger into 25 MB chunks, so 128 MB of request body covers both with room
	# to spare. Caddy answers 413 above this. Raise Zipline's limit and this
	# number together or the proxy will refuse what the app would have taken.
	request_body {
		max_size 128MB
	}

	# 8156 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8156
}
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-zipline /etc/caddy/Caddyfile`, reload,
and paste again. An error mentioning `request_body` means your Caddy predates that directive;
check `caddy version` before changing anything else. Caddy terminates TLS and speaks plain http
to the container, which is why `CORE_TRUST_PROXY` and `CORE_RETURN_HTTPS_URLS` are both true in
the compose file: without them Zipline treats every request as coming from 127.0.0.1 and hands
back `http://` links for a site that only answers on 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 `8156` or `5432`.

If you do not: delete anything for `8156` or `5432` with `sudo ufw delete allow 8156`. 8156 is
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

Zipline runs its own database migrations on the way up, so the first start is the slow one.

```bash
cd /srv/zipline
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>/api/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/healthcheck
curl -sS https://<DOMAIN>/api/setup
```

You should see, in order: the loop reaching `200`, then `{"pass":true}`, then
`{"firstSetup":true}`.

If you do not: `{"pass":true}` is the server and the database both answering, so a loop that
never reaches `200` is usually the database. Run `docker compose logs --tail 20 postgres` first,
then `docker compose logs --tail 40 zipline`. A `502` from Caddy where a `200` was expected
means the container is still working through migrations; wait and run the loop again. A
`{"firstSetup":false}` where you expected `true` means this instance already has an account, and
you should stop and find out whose before you go near the browser.

Now the part only you can do. Open https://<DOMAIN>/auth/setup in a browser. The heading reads
`Welcome to Zipline!`, above a stepper with a `Username` and a `Password` field. Create the first
account there. Then, still logged in, open Settings from the user menu at the top right, scroll
to `Generate Uploaders`, and download the `ShareX` config on Windows or the `Flameshot` script on
Linux and macOS. That download is the point of this whole install: it is what makes the
screenshot tool you already use put files on this server instead of somebody else's.

When both are done, come back to the terminal and prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/setup
curl -sS https://<DOMAIN>/api/server/public | grep -oE '"(userRegistration|firstSetup)":[a-z]+'
```

You should see: `403`, then `"userRegistration":false` and `"firstSetup":false`.

If you do not: a `200` on the first command means the wizard is still open and anyone who finds
the hostname can claim a superadmin account on your server. Stop and work out why the account
you made did not take. A `"userRegistration":true` means the compose file was edited or an older
one is still on disk; put the one from step 4 back and run `docker compose up -d`. Do not treat a
green `docker compose ps` as the finish line: a running container is not success.

## 8. First backup and restore

Two artifacts, and neither is worth anything alone. The database holds the accounts, the tokens,
the short links and one row per file. The archive holds the files themselves plus the three
configuration files that rebuild the service around them.

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

You should see: two files, both a few kilobytes on a fresh install. 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. Note
what the second archive will become: today it is kilobytes, a year from now it is every file you
have ever uploaded, so the nightly job you write later wants `rsync` on the uploads directory
rather than a tarball that doubles every month.

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/zipline
scp vps:/srv/zipline/backups/* ~/backups/zipline/
```

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

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 a test upload:

```bash
cd /srv/zipline
docker compose down
sudo rm -rf /srv/zipline/postgres /srv/zipline/uploads
sudo install -d -m 700 /srv/zipline/postgres
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/zipline/uploads
sudo tar -xzf /srv/zipline/backups/zipline-files-$(date +%F).tar.gz -C /srv/zipline uploads
docker compose up -d postgres
sleep 30
gunzip -c /srv/zipline/backups/zipline-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U zipline -d zipline
docker compose up -d
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/healthcheck
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `200` from the last command, and
your account still logs in with the password you chose.

If you do not: `role "zipline" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand the stakes before you
skip this: every link you have pasted into a ticket or a chat points at this box, and a database
restored without its uploads directory answers all of them with a missing file.

## 9. Updating later

New versions are listed at https://github.com/diced/zipline/releases. Take both backup artifacts
first, then edit the `image:` line in /srv/zipline/compose.yml to the new tag and its digest.

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

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

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
health check from step 7 before you call the update done, and open one real file link as well,
because a service that answers `{"pass":true}` can still be failing to serve files if a migration
stopped halfway.

## 10. What will probably go wrong

You will open https://<DOMAIN> in a browser, see a screen reading `404` and `Page not found`, and
conclude Caddy is pointed at nothing. I did, and I spent ten minutes re-reading the site block.
It was fine: Zipline has no home page, the root path falls through to its own not-found screen,
and the only thing at the bare hostname is a `Go home` button back to the login page. The address
that tells the truth on a fresh install is https://<DOMAIN>/auth/setup. Check that, not the root.

## 11. Out of scope

- Do not configure SMTP. Zipline sends no mail at all, so there is nothing for it to do.
- Do not register an OAuth application with Discord, GitHub, Google or an OIDC provider. A
  password login with registration closed is the whole account model this install commits to.
- Do not switch the datasource to S3. Local storage under /srv/zipline/uploads is the choice
  here, and it is the one step 8 knows how to back up.
- Do not raise `FILES_MAX_FILE_SIZE` without raising `max_size` in the Caddy block by the same
  amount. Raise one and the other returns `413` on the uploads the app would have accepted.
````

## 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 Zipline 4.6.5, with the PostgreSQL it keeps accounts and links in, under
~/selfhost/zipline, answering at http://localhost:8156.

## 1. Preflight

Say this to the user before step 2 runs; it decides whether they want this install at all.
Every link this hands back begins with http://localhost:8156, and `localhost` means "the
computer you are reading this on" to whoever opens it, so a link pasted into a chat opens for
the user and fails for everyone else. This is a private drop box, not a share service.

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. Zipline plus PostgreSQL needs 2048 MB of
RAM available and 10 GB free on the home disk, and both images publish amd64 and arm64. That
floor is the thumbnail workers rather than the web server: the image ships ffmpeg and renders
video thumbnails on four threads. On macOS and Windows the memory printed is the host's, out of
which Docker Desktop takes its own. If RAM is under 2048 MB or free 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/zipline/uploads ~/selfhost/zipline/backups
ls -la ~/selfhost/zipline
```

Assert: `ls -la` shows `uploads` and `backups`, both owned by the user. No ownership fix runs on
any of the three systems: the database lives in a volume Docker manages. The container writes
into `uploads` as root, which step 10 covers.

## 4. Secrets

Two secrets, both generated here: the PostgreSQL password and `CORE_SECRET`, which signs session
cookies. Print neither, and keep both out of your summary and out of any log line. Hex rather
than base64: one travels inside a connection string, and upstream refuses to start on a secret
under 32 characters, which 32 hex bytes clears.

```bash
umask 077
cat > ~/selfhost/zipline/.env <<EOF
CORE_SECRET=$(openssl rand -hex 32)
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/zipline/.env
umask 022
ls -l ~/selfhost/zipline/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same on
all three systems. On Windows those mode bits are advisory: NTFS does not enforce them,
and the real boundary is the user's own Windows account. Changing `CORE_SECRET` later logs every
session out, so tell the user to leave it alone once this works.

## 5. compose.yml

```bash
cat > ~/selfhost/zipline/compose.yml <<'EOF'
# Zipline · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://zipline.diced.sh/docs/get-started/docker
#   variable reference . https://zipline.diced.sh/docs/config
#   hardening guide .... https://zipline.diced.sh/docs/guides/hardening
#
# Two services, every path relative to ~/selfhost/zipline/ so one file works on
# macOS, Linux and Windows. Uploads stay a bind mount you can open in Finder;
# the database is a named volume, because PostgreSQL chowns its data directory
# to its own uid and a home-directory bind mount cannot allow that on Windows.
# Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: zipline-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: zipline
      POSTGRES_USER: zipline
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - zipline-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zipline -d zipline"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  zipline:
    image: ghcr.io/diced/zipline:4.6.5@sha256:bfd5b0f7b5b8b3ed058a81667c78a14a7f997115d8433bec273620ec81be51d4
    container_name: zipline
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgres://zipline:${DB_PASSWORD}@postgres:5432/zipline
      # No proxy and no certificate here, so plain http. Registration stays
      # closed once the wizard has made the first account. The file type is
      # read off the file rather than believed, and the two that execute in a
      # browser are served as a download.
      CORE_TRUST_PROXY: "false"
      CORE_RETURN_HTTPS_URLS: "false"
      FEATURES_USER_REGISTRATION: "false"
      FILES_ASSUME_MIMETYPES: "true"
      FILES_DISABLED_TYPES: text/html,application/javascript
      FILES_DISABLED_TYPES_DEFAULT: application/octet-stream
    volumes:
      - ./uploads:/zipline/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8156.
      - "127.0.0.1:8156:3000"
    depends_on:
      postgres:
        condition: service_healthy

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

Assert: that prints `compose OK`. Files land in ~/selfhost/zipline/uploads; everything else
about them, the owner, the short code, the view count, is a row in PostgreSQL. That is why step
8 takes two artifacts.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the upload code still works.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8156 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. For a file host that is the whole shape of the trade. Confirm it:

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

Assert: that prints `1`, the one published port `- "127.0.0.1:8156:3000"`. PostgreSQL publishes
no host port, so 5432 cannot appear.

## 7. Start and verify

Zipline migrates its own database on the way up, so the first start is the slow one.

```bash
cd ~/selfhost/zipline
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:8156/api/healthcheck); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8156/api/healthcheck
curl -sS http://localhost:8156/api/setup
```

Assert all three, and print what you received for each: the loop ends on `200`; the health call
prints `{"pass":true}`, upstream's answer when server and database are up; the setup call prints
`{"firstSetup":true}`, this instance saying it has no accounts yet. If any of the three
misses, stop, run `docker compose logs --tail 40 zipline` and
`docker compose logs --tail 20 postgres`, and name the likely cause: a database that never
reports healthy points at step 4, where an empty `DB_PASSWORD` leaves PostgreSQL refusing to
start, and a zipline log still in migrations wants more time. If `port is already allocated`
came back, find what holds 8156 (`lsof -nP -iTCP:8156 -sTCP:LISTEN`, or
`netstat -ano | findstr :8156` on Windows) and stop until the user frees it. A running container
is not success.

The first screen is at http://localhost:8156/auth/setup and its heading reads
`Welcome to Zipline!`, above a stepper with a `Username` and a `Password` field.

STOP: tell the user to open http://localhost:8156/auth/setup, create the first account, then,
still logged in, open Settings from the user menu at the top right, scroll to
`Generate Uploaders`, and download the `ShareX` config on Windows or the `Flameshot` script on
Linux and macOS. That download is the point of this install: it aims the screenshot tool they
already have at this machine. Wait. Do not continue until they confirm both.

Once they confirm, prove the door is shut:

```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8156/api/setup
curl -sS http://localhost:8156/api/server/public | grep -oE '"(userRegistration|firstSetup)":[a-z]+'
```

Assert: the first prints `403`, upstream's answer once the wizard is claimed, so no second
superadmin can be made through it. The second prints `"userRegistration":false` and
`"firstSetup":false`. Both must pass before you report success.

## 8. First backup and restore

Two artifacts, neither worth anything alone: a database dump with the accounts, tokens, short
links and one row per file, and an archive with the files plus the two config files that rebuild
the service around them.

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

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped:
`pg_dump` snapshots a running database consistently. On Linux `tar` may need `sudo`; step 10
says why.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
a sync service watches or a USB stick, and copy both there with `cp`. In Git Bash a Windows
drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms both filenames are
there. If they have neither, say plainly that this install has no backup.

To restore, in this order. `cd ~/selfhost/zipline`, untar the files archive there first so
compose.yml and .env are back before any container starts: PostgreSQL reads `DB_PASSWORD` from
.env the moment it initialises an empty volume, and a missing .env means a blank password and a
database that will not start. Then `docker compose down -v`, the one place `-v` belongs because
it drops the old volume on purpose, `docker compose up -d postgres`, wait 30 seconds, pipe
`gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U zipline -d zipline`, then `docker compose up -d`. Open
one old link and check the file comes back. That is the whole disaster plan.

## 9. Updating later

New versions are at https://github.com/diced/zipline/releases. Take both backups first, then
edit the image line in ~/selfhost/zipline/compose.yml to the new tag and digest:

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

Watch that log until it settles, then re-run step 7's health check.

## 10. What will probably go wrong

On Linux I tried to clear a test upload out of my file manager and got permission denied on a
file I had made myself. Nothing was broken: the Zipline image runs as root,
so everything under ~/selfhost/zipline/uploads belongs to root even though the folder belongs to
the user. Deleting from inside the dashboard works; from the desktop it needs `sudo`. On macOS
and Windows, Docker Desktop rewrites ownership on the way through and none of it appears.

## 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 set `CORE_DEFAULT_DOMAIN` to this machine's LAN address and do not rebind 8156 to
  0.0.0.0 so a phone can reach it. That puts a file host with an upload API on every network
  the user joins.
- Do not switch the datasource to S3. Local storage under ~/selfhost/zipline/uploads is the
  choice here, and the one step 8 backs up.
````

## docker-compose.yml

```yaml
# Zipline · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker install ..... https://zipline.diced.sh/docs/get-started/docker
#   variable reference . https://zipline.diced.sh/docs/config
#   core variables ..... https://zipline.diced.sh/docs/config/core
#   hardening guide .... https://zipline.diced.sh/docs/guides/hardening
#   reverse proxy ...... https://zipline.diced.sh/docs/guides/reverse-proxy
#
# Two services: Zipline and the PostgreSQL that holds accounts, tokens, short
# links and one row per uploaded file. The files themselves sit on disk under
# /srv/zipline/uploads, so a restore needs both halves or you get an index of
# things that are not there. Upstream's own compose file runs postgres 16, so
# this one stays on that major and pins a patch of it. Digests were read from
# the registries on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: zipline-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: zipline
      POSTGRES_USER: zipline
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - /srv/zipline/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zipline -d zipline"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  zipline:
    image: ghcr.io/diced/zipline:4.6.5@sha256:bfd5b0f7b5b8b3ed058a81667c78a14a7f997115d8433bec273620ec81be51d4
    container_name: zipline
    restart: unless-stopped
    env_file: /srv/zipline/.env
    environment:
      DATABASE_URL: postgres://zipline:${DB_PASSWORD}@postgres:5432/zipline
      # Caddy terminates TLS and speaks plain http here. Upstream asks for both
      # of these behind a proxy: without the first, every request looks like it
      # came from 127.0.0.1; without the second, the links handed back start
      # with http:// on a site only reachable over https.
      CORE_TRUST_PROXY: "true"
      CORE_RETURN_HTTPS_URLS: "true"
      # Registration is off upstream by default and this pins it off, so the
      # only account ever made is the first one, through the setup wizard.
      FEATURES_USER_REGISTRATION: "false"
      # Read the type off the file instead of believing the uploader, and serve
      # the two that execute in a browser as a download rather than inline.
      FILES_ASSUME_MIMETYPES: "true"
      FILES_DISABLED_TYPES: text/html,application/javascript
      FILES_DISABLED_TYPES_DEFAULT: application/octet-stream
    volumes:
      - /srv/zipline/uploads:/zipline/uploads
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8156.
      - "127.0.0.1:8156:3000"
    depends_on:
      postgres:
        condition: service_healthy
```

## compose.local.yml

```yaml
# Zipline · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker install ..... https://zipline.diced.sh/docs/get-started/docker
#   variable reference . https://zipline.diced.sh/docs/config
#   hardening guide .... https://zipline.diced.sh/docs/guides/hardening
#
# Two services, every path relative to ~/selfhost/zipline/ so one file works on
# macOS, Linux and Windows. Uploads stay a bind mount you can open in Finder;
# the database is a named volume, because PostgreSQL chowns its data directory
# to its own uid and a home-directory bind mount cannot allow that on Windows.
# Digests read on 2026-08-07; both images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:16.14-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
    container_name: zipline-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: zipline
      POSTGRES_USER: zipline
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - zipline-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U zipline -d zipline"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  zipline:
    image: ghcr.io/diced/zipline:4.6.5@sha256:bfd5b0f7b5b8b3ed058a81667c78a14a7f997115d8433bec273620ec81be51d4
    container_name: zipline
    restart: unless-stopped
    env_file: ./.env
    environment:
      DATABASE_URL: postgres://zipline:${DB_PASSWORD}@postgres:5432/zipline
      # No proxy and no certificate here, so plain http. Registration stays
      # closed once the wizard has made the first account. The file type is
      # read off the file rather than believed, and the two that execute in a
      # browser are served as a download.
      CORE_TRUST_PROXY: "false"
      CORE_RETURN_HTTPS_URLS: "false"
      FEATURES_USER_REGISTRATION: "false"
      FILES_ASSUME_MIMETYPES: "true"
      FILES_DISABLED_TYPES: text/html,application/javascript
      FILES_DISABLED_TYPES_DEFAULT: application/octet-stream
    volumes:
      - ./uploads:/zipline/uploads
    ports:
      # Loopback only: no other device on the wifi can reach 8156.
      - "127.0.0.1:8156:3000"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  zipline-pgdata:
```

## Caddyfile

```text
# Zipline · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://zipline.diced.sh/docs/guides/reverse-proxy,
# https://caddyserver.com/docs/caddyfile/directives/request_body 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 hostname is
# the front of every share link this server hands out, so it is the value here
# worth choosing once and keeping.

<DOMAIN> {
	# Uploads are mostly already-compressed image and video formats, so there
	# is nothing to gain by compressing them again. These four headers are the
	# part worth having: a file host serves other people's bytes to strangers.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
	}

	# Zipline's own default file size limit is 100 MB and it cuts anything
	# larger into 25 MB chunks, so 128 MB of request body covers both with room
	# to spare. Caddy answers 413 above this. Raise Zipline's limit and this
	# number together or the proxy will refuse what the app would have taken.
	request_body {
		max_size 128MB
	}

	# 8156 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8156
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Zipline · 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=share.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://zipline.diced.sh/docs/get-started/docker
#   https://zipline.diced.sh/docs/config
#   https://zipline.diced.sh/docs/config/core
#   https://zipline.diced.sh/docs/guides/hardening
#   https://zipline.diced.sh/docs/guides/reverse-proxy
#
# Two secrets are generated here, on this machine: the PostgreSQL password and
# CORE_SECRET, which signs session cookies. Both go into /srv/zipline/.env with
# mode 600 and neither is ever printed.
#
# This script stops one step short of a finished install, on purpose. Only a
# human can open a browser and claim the setup wizard, so it verifies that the
# wizard is waiting and hands you the URL.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/zipline}"
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. share.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 2048 ] || die "only ${avail_mb} MB of RAM available; Zipline plus PostgreSQL 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 ----------------------------------------------------
#
# postgres stays root-owned at 700: the PostgreSQL image chowns its own data
# directory on first start and refuses one that already belongs elsewhere.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/uploads" "$APP_DIR/backups"
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 two secrets, on the server ------------------------------
#
# Hex rather than base64 for both: the database password travels inside a
# connection string, and upstream refuses to start on a CORE_SECRET shorter
# than 32 characters, which 32 hex bytes clears. Changing CORE_SECRET later
# logs every session out, including yours.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		CORE_SECRET=$(openssl rand -hex 32)
		DB_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

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-zipline"
	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 neither 8156 nor 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; 8156 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 -------------------------------------------------------------
#
# Zipline runs its own database migrations on the way up, so the first start is
# the slow one.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/healthcheck"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/healthcheck" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/api/healthcheck answered ${code:-nothing}. Check: docker compose logs --tail 40 zipline"

curl -sS "https://${DOMAIN_HOST}/api/healthcheck" | grep -q '"pass":true' \
	|| die "/api/healthcheck answered 200 without pass true. Check: docker compose logs --tail 40 zipline"

# The setup wizard must be unclaimed and registration must be closed. Both are
# assertions with real security meaning: the first says nobody has taken the
# superadmin account before you, the second says nobody can sign themselves up.
curl -sS "https://${DOMAIN_HOST}/api/setup" | grep -q '"firstSetup":true' \
	|| die "/api/setup did not report firstSetup true. If this instance already has an account, stop and find out whose."

curl -sS "https://${DOMAIN_HOST}/api/server/public" | grep -q '"userRegistration":false' \
	|| die "public settings do not report userRegistration false. Stop and investigate before anyone finds this host."

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

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

cat <<-DONE

	Zipline is answering at https://${DOMAIN_HOST}/api/healthcheck

	  1. Open https://${DOMAIN_HOST}/auth/setup and create the first account.
	     The heading reads "Welcome to Zipline!". Nobody else can create one:
	     registration is off, and the wizard closes the moment you use it.
	     The bare hostname shows a 404 screen because Zipline has no home
	     page. That is correct, not a broken proxy.
	  2. Still logged in, open Settings from the user menu and scroll to
	     "Generate Uploaders". Download the ShareX config on Windows, or the
	     Flameshot script on Linux and macOS. That file is what points the
	     screenshot tool you already use at this server.
	  3. Two secrets live in $APP_DIR/.env, mode 600, and neither was printed
	     here. You do not need to read them. Changing CORE_SECRET later logs
	     every session out, including yours.
	  4. First backup written to $APP_DIR/backups: a database dump and an
	     archive of the uploads plus the config. They are on the same disk as
	     the data, which is not a backup. Copy them off the box tonight, and
	     remember the archive grows with every file you upload.

DONE
```

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