# Can I self-host TickTick?

**YES** — it's called Super Productivity. ONE COMMAND setup · ~8 minutes to running · 256 MB RAM minimum · $3/mo you stop paying ($36/yr on the Premium plan).

Super Productivity authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/ticktick/

## 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 Super Productivity v18.19.0 on that server, reachable at https://<DOMAIN>, behind the
existing Caddy with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer. Its
A record must already point at this server. Say three things when you ask, because they decide
whether this is the right install at all.

One: this server holds none of their tasks. The image is nginx serving a built web app as static
files, and upstream states that data is stored in the browser and the container provides no
persistent storage, so clearing site data for the hostname deletes the lot.

Two: with no server-side store there is no account and no login, and the page answers everybody. A
stranger who loads the URL gets their own empty list, not a window into the user's. Caddy
`basic_auth` is the opt-in and this install does not enable it.

Three: cross-device sync is not one setting away, and it is what a TickTick subscriber most wants
back. Block 8 carries the two measured reasons this install leaves it off.

Super Productivity needs 256 MB of RAM available and 2 GB free on /srv. The image is about 320 MB
and idles at nearly nothing. The pinned tag publishes linux/amd64 and linux/arm64. Measure:

```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 256 MB or free disk is under 2 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: the certificate is
load-bearing here, because browsers grant a service worker and WebCrypto only on a secure origin.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/super-productivity /srv/super-productivity/backups
ls -la /srv/super-productivity
```

Assert: `ls -la` shows `backups` owned by the login user. There is no `data/` directory: step 4
mounts nothing, so a `data/` here would archive empty space and imply a safety that does not exist.
Say that to the user now.

## 3. Secrets

No secret is generated, there is no `.env` file, and there is no default credential for step 7 to
close. This build ships no account, no registration form and no administration screen, so there is
no first claimant and no claim race. Do not invent a setup step for software with none.

What replaces it is publication, and a sync credential this install never touches. The image's
entrypoint can write `assets/sync-config-default-override.json` from `WEBDAV_BASE_URL`,
`WEBDAV_USERNAME`, `WEBDAV_SYNC_FOLDER_PATH`, `SYNC_INTERVAL`, `IS_COMPRESSION_ENABLED` and
`IS_ENCRYPTION_ENABLED`, and that file is served to every visitor. Upstream provides no variable
for a password: the sync password is typed into the browser and stored by the browser. Step 4 sets
none of those variables, so the served file stays the one comment line the image ships, and step 7
prints it as evidence.

## 4. compose.yml

```bash
cat > /srv/super-productivity/compose.yml <<'EOF'
# Super Productivity · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   run with docker .... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docs/wiki/2.13-Run-with-Docker.md
#   entrypoint ......... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docker-entrypoint.sh
#   nginx template ..... https://github.com/super-productivity/super-productivity/blob/v18.19.0/nginx/default.conf.template
#
# One service: nginx serving the built web app as static files. No database, no
# volume, no state on this box, because upstream states that data is stored in
# the browser and the container provides no persistent storage. No env_file and
# no environment block, on purpose: the entrypoint would write a world-readable
# sync-config-default-override.json under assets/ from the WEBDAV_ and
# SYNC_INTERVAL variables, and with WEBDAV_BACKEND unset the image's /webdav/
# proxy answers 404. Block 8 of the prompts says why. No
# `user:` line: nginx binds 80 in the container and the image names no
# unprivileged user; the healthcheck uses the curl the image installs.
#
# Image identity, checked 2026-08-14: the wiki's docker run line names a Docker
# Hub repository that does not resolve; the one that does, and that upstream's
# own compose file names, is johannesjo/super-productivity. Tag v18.19.0 was
# released 2026-08-07; digest read from registry-1.docker.io on 2026-08-14, an
# OCI index covering linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  super-productivity:
    image: johannesjo/super-productivity:v18.19.0@sha256:ae91fe9ac19561e0f3669d15a2c4c71d7a75c43a29eb44ddc010ae50d1f63c82
    container_name: super-productivity
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8199.
      - "127.0.0.1:8199:80"
EOF
cd /srv/super-productivity && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, no volumes, no env_file. Do not
add a Caddy service here: Caddy already runs under systemd on this box.

## 5. Caddy and TLS

Write the site block to `/srv/super-productivity/Caddyfile`, then append it to the live Caddyfile
with `<DOMAIN>` replaced. Copy the live file first: a syntax error takes down every site here.

```bash
cat > /srv/super-productivity/Caddyfile <<'EOF'
# Super Productivity · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# 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. What is served here is
# public by design: there are no accounts, because there is no server-side store
# to have an account in, and a stranger loading this URL gets an empty task list
# in their own browser rather than a view of yours. Caddy basic_auth is the
# opt-in. TLS is not decoration here: the app registers a service worker and
# uses WebCrypto, and browsers grant neither on a plain-http hostname.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8199 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:8199
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-super-productivity
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/super-productivity/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Set `DOMAIN_HOST` to the real hostname from step 1 before running `sed`. Assert: `caddy validate`
exits 0 and the reload exits 0. If validate fails, restore
`/etc/caddy/Caddyfile.before-super-productivity`, reload, and report what it objected to. Caddy
issues the certificate on the first request and renews it on its own.

## 6. Firewall

Two ports open, both Caddy's, both idempotent:

```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, and 443/udp
is HTTP/3. 8199 stays closed because compose binds it to 127.0.0.1. Assert: `ufw status verbose`
prints `Status: active`, shows 80, 443/tcp and 443/udp, and no rule mentioning 8199.

## 7. Start and verify

```bash
cd /srv/super-productivity
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sSL https://<DOMAIN>/ | grep -c '<title>Super Productivity</title>'
curl -sS https://<DOMAIN>/assets/sync-config-default-override.json; echo
curl -sS https://<DOMAIN>/assets/sync-config-default-override.json | grep -ciE 'password|userName|baseUrl|syncFolderPath'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/webdav/
docker compose ps
```

Assert all five and print what you received. The loop ends printing `200`. The title grep prints
`1`, because the built page carries `<title>Super Productivity</title>`. The third command prints
the served sync defaults, a single `_comment` key and nothing else. The fourth prints `0`, and that
zero is the security assert here: it proves the file the server hands every visitor names no
account, no server and no credential, because step 4 set none of the variables that would put them
there. The fifth prints `404`, the image's own proxy route answering with `WEBDAV_BACKEND` unset.

If any miss, stop, run `docker compose logs --tail 40 super-productivity`, and name the likely
earlier step. A 502 with a healthy container is step 5. A `200` with a title count of `0` means
another site block owns the hostname. Anything but `404` on the last line means an environment
variable reached the container that step 4 did not put there. A running container is not success.

STOP: tell the user to open https://<DOMAIN> in a private window, add one throwaway task, and
confirm two things: that the app loads with no login and the task appears, and that they are content
for anyone reaching this hostname to load the same app, knowing that gives them their own empty list
and no access to theirs. Do not continue until they confirm.

## 8. First backup and restore

Two backups here, and they are not the same thing. Do both, in this order.

The first is this box: one compose file and the live Caddy site block. Nothing under
/srv/super-productivity is written by the app, so the container need not stop.

```bash
cd /srv/super-productivity
sudo tar -czf /srv/super-productivity/backups/super-productivity-$(date +%F).tar.gz \
  -C /srv/super-productivity compose.yml \
  -C /etc/caddy Caddyfile
ls -lh /srv/super-productivity/backups/
```

Assert: the archive exists and is non-empty. Print its size. If that tar reports an error, stop
and report it.

A backup on the same disk as the data is not a backup. Run this one from the user's machine, not
the server:

```bash
mkdir -p ~/backups/super-productivity
scp vps:/srv/super-productivity/backups/*.tar.gz ~/backups/super-productivity/
```

To restore this box cold: recreate the directories as in step 2, untar the archive into
/srv/super-productivity, append the Caddy block to /etc/caddy/Caddyfile with `<DOMAIN>` replaced as
in step 5, validate, reload, then `cd /srv/super-productivity && docker compose up -d`. Same app,
same URL, no tasks, because none were ever here.

The second backup is the user's, and it is the one that matters. In the app: Settings, the
Sync & Backup tab, then Export data. That downloads one plaintext JSON file of tasks, projects,
tags, time tracking, notes, metrics and archives, which upstream describes as a restorable snapshot
of the application model. Import in the same place replaces current data with it, and that is the
restore. Keep the file off the machine that downloaded it and treat it as private: it is not
encrypted. The web build schedules no automatic file backup, so this export is the mechanism.

STOP: tell the user to export that file now, before a real week of work goes in, and to confirm both the filename and that a copy of it is off the machine that downloaded it. Do not continue until they confirm.

Sync would make that export a fallback rather than the plan, and this install does not configure
it, for two reasons measured against this image on 2026-08-14. Upstream states browser WebDAV sync
is likely to fail on CORS, and Nextcloud sends no CORS headers on its WebDAV endpoints. The image's
own same-origin answer, an nginx `/webdav/` route pointed at `WEBDAV_BACKEND`, refuses uploads over
its 1 MB `client_max_body_size` default with 413 and sends no TLS server name upstream, so an HTTPS
Nextcloud sharing an address with other sites can fail the handshake. Anyone taking that route must
set `WEBDAV_BACKEND` to a scheme and host with no path and no trailing slash, because a path there
replaces the whole request URI. For a Nextcloud user the reliable answer is the desktop build, which
is not subject to CORS.

## 9. Updating later

New versions are listed at https://github.com/super-productivity/super-productivity/releases. The
release tag and the image tag are the same string, and the image lives at
`johannesjo/super-productivity` even though the repository is now
`super-productivity/super-productivity`. Take a backup first, then edit the image line in
compose.yml to the new tag and its digest:

```bash
cd /srv/super-productivity
docker compose pull
docker compose up -d
docker compose logs --tail 20 super-productivity
```

Re-run step 7's asserts before calling the update done. The app also updates itself through its
service worker and asks the browser to reload, so a user with the tab open may be prompted minutes
later. Their data is in that browser: tell them to reload, not to clear.

## 10. What will probably go wrong

I opened this on my laptop, added a week of tasks, then opened the same URL on my phone, found an
empty list, and spent ten minutes sure the install was broken. It was not. There is no server-side
store, so the phone got its own copy of the app and its own empty database, and nothing was lost.
That is the one fact to hold on to, and it is why step 8 makes you export a file. Second: the
app refuses to run in two tabs at once and shows a blocker asking you to close one, which reads
like a crash until you notice the other tab. Third: if the browser denies the app's request for
persistent storage it says so once, in a small notification, and that is the only warning before
the browser can evict your tasks under disk pressure.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy already runs under systemd on this box.
- Do not publish 8199 on `0.0.0.0` or open it in the firewall.
- Do not invent accounts, a first-run wizard, or a claim-race warning for a build that has none.
- Do not set `WEBDAV_BACKEND` or any `WEBDAV_` variable in this session, and do not install
  SuperSync: it is upstream's own sync server, beta by upstream's own description, and it wants a
  Postgres and a second container.
````

## 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 Super Productivity v18.19.0 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 these three before step 1, because they decide whether you want this install rather than how it
goes. The server will hold none of your tasks: the image is nginx serving a built web app as static
files, and upstream states that data is stored in the browser and the container provides no
persistent storage, so everything you type lives in the browser profile that loaded the page and
clearing site data for the hostname deletes it. There is no account and no login, because there is
no server-side store to have an account in, so the page answers everybody and a stranger who loads
it gets their own empty list rather than a view of yours. And cross-device sync is not one setting
away; step 8 has the two measured reasons this install leaves it off.

## 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 `256` MB available, at least `2` G free, `amd64` or `arm64`, and your
server's IP address.

If you do not: with under 256 MB or under 2 GB, stop and resize the box rather than installing and
hoping. If `dig +short` prints nothing, add the A record and wait a minute. Caddy cannot issue a
certificate for a name that does not resolve, and the certificate is load-bearing here, because
browsers grant a service worker and WebCrypto only on a secure origin. `df -BG` is a GNU coreutils
flag; on a box that is neither Debian nor Ubuntu, run `df -h /srv` and read the figure yourself.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/super-productivity /srv/super-productivity/backups
ls -la /srv/super-productivity
```

You should see: `backups` under /srv/super-productivity, owned by you.

If you do not: check that you ran this as your login user and not as root. There is deliberately no
`data/` directory here; the compose file in step 4 mounts nothing, so a `data/` would be an empty
archive pretending to be a safety net.

## 3. Secrets

Nothing to generate. There is no `.env` file, no default credential, no registration form and no
administration screen, so there is no first claimant and no claim race, and no step here to run.

Two things to know instead. The image's entrypoint can write
`assets/sync-config-default-override.json` from `WEBDAV_BASE_URL`, `WEBDAV_USERNAME`,
`WEBDAV_SYNC_FOLDER_PATH`, `SYNC_INTERVAL`, `IS_COMPRESSION_ENABLED` and `IS_ENCRYPTION_ENABLED`,
and that file is served to everyone who loads your URL. Step 4 sets none of them, and step 7 prints
the file as proof. Upstream provides no variable for a password in any case: a sync password is
typed into the browser and kept by the browser, never by this server.

The second is a chat-window rule. Do not paste your sync password, any app password from another
service, or the contents of the JSON file step 8 exports into this chat. That export is plaintext
and it can carry the API credentials of any issue provider you connect later. The other tab's agent
never sees those values; this one will, if you paste them.

## 4. compose.yml

```bash
cat > /srv/super-productivity/compose.yml <<'EOF'
# Super Productivity · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   run with docker .... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docs/wiki/2.13-Run-with-Docker.md
#   entrypoint ......... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docker-entrypoint.sh
#   nginx template ..... https://github.com/super-productivity/super-productivity/blob/v18.19.0/nginx/default.conf.template
#
# One service: nginx serving the built web app as static files. No database, no
# volume, no state on this box, because upstream states that data is stored in
# the browser and the container provides no persistent storage. No env_file and
# no environment block, on purpose: the entrypoint would write a world-readable
# sync-config-default-override.json under assets/ from the WEBDAV_ and
# SYNC_INTERVAL variables, and with WEBDAV_BACKEND unset the image's /webdav/
# proxy answers 404. Block 8 of the prompts says why. No
# `user:` line: nginx binds 80 in the container and the image names no
# unprivileged user; the healthcheck uses the curl the image installs.
#
# Image identity, checked 2026-08-14: the wiki's docker run line names a Docker
# Hub repository that does not resolve; the one that does, and that upstream's
# own compose file names, is johannesjo/super-productivity. Tag v18.19.0 was
# released 2026-08-07; digest read from registry-1.docker.io on 2026-08-14, an
# OCI index covering linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  super-productivity:
    image: johannesjo/super-productivity:v18.19.0@sha256:ae91fe9ac19561e0f3669d15a2c4c71d7a75c43a29eb44ddc010ae50d1f63c82
    container_name: super-productivity
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8199.
      - "127.0.0.1:8199:80"
EOF
cd /srv/super-productivity && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK`.

If you do not: `docker compose config` prints the line it objected to, and it is almost always an
indentation change made while pasting. Re-paste the block in one go rather than editing it in place.
Do not add a Caddy service here; Caddy already runs under systemd on this box.

## 5. Caddy and TLS

```bash
cat > /srv/super-productivity/Caddyfile <<'EOF'
# Super Productivity · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# 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. What is served here is
# public by design: there are no accounts, because there is no server-side store
# to have an account in, and a stranger loading this URL gets an empty task list
# in their own browser rather than a view of yours. Caddy basic_auth is the
# opt-in. TLS is not decoration here: the app registers a service worker and
# uses WebCrypto, and browsers grant neither on a plain-http hostname.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8199 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:8199
}
EOF
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-super-productivity
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
DOMAIN_HOST=<DOMAIN>
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" /srv/super-productivity/Caddyfile | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Set `DOMAIN_HOST` to your real hostname before the `sed` line runs.

You should see: `Valid configuration` from `caddy validate`, and no output from the reload.

If you do not: restore the copy from a moment ago with
`sudo cp /etc/caddy/Caddyfile.before-super-productivity /etc/caddy/Caddyfile`, reload, and read what
validate objected to. The usual cause is `DOMAIN_HOST` still being literal, which writes the angle
brackets into the site name.

## 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`, with 80/tcp, 443/tcp and 443/udp allowed and nothing mentioning
8199.

If you do not: 80/tcp answers the ACME challenge, 443/tcp is the only way in, 443/udp is HTTP/3.
8199 must never appear; compose binds it to 127.0.0.1, so opening it in the firewall would publish
the container past the reverse proxy. If a previous run added it, remove it with
`sudo ufw delete allow 8199`.

## 7. Start and verify

```bash
cd /srv/super-productivity
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sSL https://<DOMAIN>/ | grep -c '<title>Super Productivity</title>'
curl -sS https://<DOMAIN>/assets/sync-config-default-override.json; echo
curl -sS https://<DOMAIN>/assets/sync-config-default-override.json | grep -ciE 'password|userName|baseUrl|syncFolderPath'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/webdav/
docker compose ps
```

You should see: the loop ending on `200`; a title count of `1`; a served sync-defaults file
containing a single `_comment` key and nothing else; a `0` from the grep after it; and `404` from
the /webdav/ line. That `0` is the security check on this install: it proves the file your server
hands to every visitor names no account, no server and no credential. The `404` is the image's own
proxy route answering with `WEBDAV_BACKEND` unset, so no second door is open on your hostname.

If you do not: a 502 with a healthy container means the Caddy step, so re-read step 5. A `200` with
a title count of `0` usually means another site block already owns the hostname, so look through
/etc/caddy/Caddyfile for a duplicate. Anything other than `404` on the last line means an
environment variable reached the container that step 4 did not put there. In every case run
`docker compose logs --tail 40 super-productivity` before changing anything. A running container is
not success.

One more thing before you trust a browser tab as evidence: the app registers a service worker, so a
browser that has loaded the page once keeps serving its cached copy even while the container is
down. Judge the install by the curl output and `docker compose ps` on the server, never by whether
the tab still looks fine.

STOP: open https://<DOMAIN> in a private window, add one throwaway task, and confirm two things to yourself: that the app loads with no login and the task appears, and that you are content for anyone who reaches this hostname to load the same app, knowing that gives them their own empty list and no access to yours. Do not continue until both are true.

## 8. First backup and restore

Two backups, and they are not the same thing. Do both, in this order.

The first is this box, and it is small on purpose: the compose file and the live Caddy site block.
Nothing under /srv/super-productivity is written by the app, so the container does not need to stop.

```bash
cd /srv/super-productivity
sudo tar -czf /srv/super-productivity/backups/super-productivity-$(date +%F).tar.gz \
  -C /srv/super-productivity compose.yml \
  -C /etc/caddy Caddyfile
ls -lh /srv/super-productivity/backups/
```

You should see: one `.tar.gz` listed, a few kilobytes, not zero bytes.

If you do not: read the tar error rather than moving past it. A zero-byte archive means the paths
were wrong, and an archive you never open is not a backup. Then copy it off the box, from your own
machine and not the server:

```bash
mkdir -p ~/backups/super-productivity
scp vps:/srv/super-productivity/backups/*.tar.gz ~/backups/super-productivity/
```

List the archive before you trust it, because an archive nobody has opened is a hope:

```bash
tar -tzf /srv/super-productivity/backups/*.tar.gz
```

You should see: exactly two names, `compose.yml` and `Caddyfile`.

To restore this box cold: recreate the two directories as in step 2, untar the archive into
/srv/super-productivity, append the Caddy site block to /etc/caddy/Caddyfile with `<DOMAIN>`
replaced as in step 5, validate, reload, then `cd /srv/super-productivity && docker compose up -d`.
That gives you the same app at the same URL, and no tasks, because none were ever here.

The second backup is yours, and it is the one that matters. In the app: Settings, the Sync & Backup
tab, then Export data. That downloads one plaintext JSON file of tasks, projects, tags, time
tracking, notes, metrics and archives, which upstream describes as a restorable snapshot of the
application model. Import in the same place replaces current data with it, and that is your restore.
Keep the file off the machine that downloaded it, and treat it as private: it is not encrypted. The
web build schedules no automatic file backup, so this export is the mechanism.

STOP: export that file now, before a real week of work goes in, and put a copy somewhere that is not the machine that downloaded it. Do not continue until that copy exists.

Sync would make that export a fallback rather than the plan, and this install does not configure it,
for two reasons measured against this image on 2026-08-14. Upstream states browser WebDAV sync is
likely to fail on CORS, and Nextcloud sends no CORS headers on its WebDAV endpoints. The image's own
same-origin answer, an nginx `/webdav/` route pointed at `WEBDAV_BACKEND`, refuses uploads over its
1 MB `client_max_body_size` default with 413 and sends no TLS server name upstream, so an HTTPS
Nextcloud that shares an address with other sites can fail the handshake. If you take that route
anyway, `WEBDAV_BACKEND` has to be a scheme and host with no path and no trailing slash, because a
path there replaces the whole request URI. For a Nextcloud user the reliable answer is the desktop
build, which is not subject to CORS.

## 9. Updating later

New versions are listed at https://github.com/super-productivity/super-productivity/releases. The
release tag and the image tag are the same string, and the image lives at
`johannesjo/super-productivity` even though the repository is now
`super-productivity/super-productivity`. Back up first, then edit the image line in
/srv/super-productivity/compose.yml to the new tag and its digest:

```bash
cd /srv/super-productivity
docker compose pull
docker compose up -d
docker compose logs --tail 20 super-productivity
```

You should see: the new image pulled, the container recreated, and a quiet nginx log. Read the
release notes before moving a pin: this project ships several releases a month, and v18.19.0,
released on 2026-08-07, will not stay the newest for long.

If you do not: the most common cause is a digest that does not match the tag you typed. Put the old
tag and digest back, run the same three commands, and the previous version returns. The app also
updates itself through its service worker and asks the browser to reload, so you may be prompted
minutes after the container changed. Reload; do not clear site data, because your tasks are in that
browser.

## 10. What will probably go wrong

I opened this on my laptop, added a week of tasks, then opened the same URL on my phone, found an
empty list, and spent ten minutes sure the install was broken. It was not. There is no server-side
store, so the phone got its own copy of the app and its own empty database, and nothing was lost.
That is the one fact to hold on to, and it is why step 8 makes you export a file. Second: the app
refuses to run in two tabs at once and shows a blocker asking you to close one, which reads like a
crash until you notice the other tab. Third: if the browser denies the app's request for persistent
storage it says so once, in a small notification, and that is the only warning before the browser
can evict your tasks under disk pressure.

## 11. Out of scope

- Do not add a Caddy container to the compose file. Caddy already runs under systemd on this box.
- Do not publish 8199 on `0.0.0.0` or open it in the firewall.
- Do not invent accounts or a first-run wizard for a build that has none.
- Do not set `WEBDAV_BACKEND` or any `WEBDAV_` variable in this session, and do not install
  SuperSync: it is upstream's own sync server, beta by upstream's own description, and it wants a
  Postgres and a second container.

If a check fails, name the step before changing anything else. Preflight is step 1. Compose errors
are step 4. Certificate and 502 problems are step 5. Ports that should be closed are step 6. A 200
with the wrong body is step 4 or 5. An empty archive is step 8.

NOT YET VERIFIED: no harness run has been recorded against this install path.
````

## 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 Super Productivity v18.19.0 under ~/selfhost/super-productivity, answering at
http://localhost:8199.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at all.
Super Productivity is local-first, and on this path that is literal twice over. The container is
nginx handing a built web app to the browser; upstream states that data is stored in the browser
and the container provides no persistent storage. So everything they type lives in this computer's
browser profile at http://localhost:8199, clearing site data for that address deletes their tasks,
and no phone or second laptop can open the list at all. What they get is a task list with
timeboxing, a Pomodoro timer and time tracking that never leaves this machine, and a backup that is
a file they export and keep.

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. Super
Productivity needs 256 MB of RAM available and 2 GB free on the home disk; the image is about
320 MB and publishes linux/amd64 and linux/arm64. If available RAM is under 256 MB or free disk is
under 2 GB, print both numbers and stop. Do not install and hope.

## 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/super-productivity/backups
ls -la ~/selfhost/super-productivity
```

Assert: `backups` exists. There is no `data/` directory and no ownership fix to run on any of the
three systems, because step 5 mounts nothing: the container writes nothing you would want back.

## 4. Secrets

No secret is generated and there is no `.env` file. This build ships no account, no registration
form and no administration screen, so there is nothing to name, rotate or close, and no default
credential to worry about. Do not invent a first-run setup step for software with none.

One thing is worth saying anyway. The image's entrypoint can write
`assets/sync-config-default-override.json` from `WEBDAV_BASE_URL`, `WEBDAV_USERNAME`,
`WEBDAV_SYNC_FOLDER_PATH`, `SYNC_INTERVAL`, `IS_COMPRESSION_ENABLED` and `IS_ENCRYPTION_ENABLED`,
and step 5 sets none of them. Upstream provides no variable for a password in any case: a sync
password is typed into the browser and stored by the browser, never by this container.

## 5. compose.yml

```bash
cat > ~/selfhost/super-productivity/compose.yml <<'EOF'
# Super Productivity · the deterministic fallback for the local path. Authored
# by caniselfhostit from the upstream documentation, not copied from a
# repository:
#   run with docker .... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docs/wiki/2.13-Run-with-Docker.md
#   image build ........ https://github.com/super-productivity/super-productivity/blob/v18.19.0/Dockerfile
#   entrypoint ......... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docker-entrypoint.sh
#
# One service on the computer you are sitting at. No volumes and no bind mounts,
# because there is nothing to mount: the image is nginx serving the built web
# app as static files, and every task you type is stored by the browser under
# http://localhost:8199, not by this container. That is also why there is no
# .env and no environment block. Browsers treat http://localhost as a secure
# context, so the parts of the app that need WebCrypto keep working without a
# certificate.
#
# Tag v18.19.0 was released 2026-08-07; digest read from registry-1.docker.io on
# 2026-08-14, an OCI index covering linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  super-productivity:
    image: johannesjo/super-productivity:v18.19.0@sha256:ae91fe9ac19561e0f3669d15a2c4c71d7a75c43a29eb44ddc010ae50d1f63c82
    container_name: super-productivity
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    ports:
      # Loopback only: no other device on the wifi can reach 8199.
      - "127.0.0.1:8199:80"
EOF
cd ~/selfhost/super-productivity && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, no volumes, no env file.

## 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 that nothing here has. Browsers treat
http://localhost as a secure context anyway, which matters more for this app than for most: it
registers a service worker and uses WebCrypto, and both need that secure context. Nothing is
published beyond loopback, so no port needs closing. Confirm the binding:

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

Assert: that count is exactly `1`. The user's phone cannot reach 8199, nor can a laptop on the same
wifi. For most apps that is a fair trade. For a task list it is the trade, because being open on a
second device is what a subscription was buying.

## 7. Start and verify

```bash
cd ~/selfhost/super-productivity
docker compose pull
docker compose up -d
for i in $(seq 1 24); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8199/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sSL http://localhost:8199/ | grep -c '<title>Super Productivity</title>'
curl -sS http://localhost:8199/assets/sync-config-default-override.json; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8199/webdav/
docker compose ps
```

Assert all four and print what you received. The loop ends printing `200`. The title grep prints
`1`. The third command prints the served sync defaults, a single `_comment` key and nothing else,
which is the evidence that no server, account or credential was baked into the page. The fourth
prints `404`, the image's own proxy route answering with `WEBDAV_BACKEND` unset. If
`port is already allocated` came back, find what holds 8199 (`lsof -nP -iTCP:8199 -sTCP:LISTEN`,
`ss -ltnp | grep 8199` on Linux, `netstat -ano | findstr :8199` on Windows) and stop until the user
frees it. There is no sign-in step and no wizard. A running container is not success.

STOP: tell the user to open http://localhost:8199, add one throwaway task, and confirm the app loads with no login and the task appears. Do not continue until they confirm.

## 8. First backup and restore

Two backups, and they are not the same thing. Do both.

The first is this folder, and it is one file, because nothing else here is yours:

```bash
cd ~/selfhost/super-productivity
tar -C ~/selfhost/super-productivity -czf ~/selfhost/super-productivity/backups/super-productivity-$(date +%F).tar.gz compose.yml
ls -lh ~/selfhost/super-productivity/backups/
```

Assert: the archive exists and is non-empty. Print its size. To restore it: untar `compose.yml`
back into ~/selfhost/super-productivity and run `docker compose up -d`. That returns the same app
at the same address, and no tasks, because none were ever in this folder.

The second backup is the user's, and it is the one that matters. In the app: Settings, the
Sync & Backup tab, then Export data. That downloads one plaintext JSON file of tasks, projects,
tags, time tracking, notes, metrics and archives, which upstream describes as a restorable snapshot
of the application model. Import in the same place replaces current data with it, and that is the
restore. On the web build there is no automatic file backup to schedule, so this export is the
mechanism, and on a laptop the disk and the machine fail together.

STOP: tell the user to export that file now, then copy it somewhere that leaves this computer, a folder their sync service watches or a USB stick, and confirm the filename is listed there. Do not continue until they confirm.

## 9. Updating later

New versions are listed at https://github.com/super-productivity/super-productivity/releases. The
release tag and the image tag are the same string, and the image lives at
`johannesjo/super-productivity` even though the repository is now
`super-productivity/super-productivity`. Export first, then edit the image line in
~/selfhost/super-productivity/compose.yml to the new tag and its digest:

```bash
cd ~/selfhost/super-productivity
docker compose pull
docker compose up -d
docker compose logs --tail 20 super-productivity
```

Re-run step 7's checks. The app also updates itself through its service worker and asks the browser
to reload, so the user may be prompted minutes later. Their data is in that browser: reload, do not
clear.

## 10. What will probably go wrong

I rebooted, opened the bookmark out of habit, and got a connection error, which read like lost data
for about a minute. Docker Desktop had not started, so nothing was serving 8199; the tasks were
still in the browser, untouched, because they were never in the container. Turn on Docker Desktop's
start-at-login setting, and after any reboot run
`cd ~/selfhost/super-productivity && docker compose up -d` before believing anything is wrong. Two
more, both local-specific. The app refuses to run in two tabs at once and shows a blocker asking
you to close one, which reads like a crash until you notice the other tab. And a browser profile is
a fragile place to keep a working week: a cleaning extension, a privacy setting, or a denied
persistent-storage request can take the lot, which is why step 8 asks for a file off this machine.

## 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 8199 to 0.0.0.0 so a phone on the wifi can load it. It would not help: the phone
  would get its own empty list, because the data is in this browser and not in the container.
- Do not set `WEBDAV_BACKEND` or any `WEBDAV_` variable, and do not install SuperSync. Both are
  sync work, and sync on this build is a decision to make after reading the project page, not a
  variable to add here.
````

## docker-compose.yml

```yaml
# Super Productivity · the deterministic fallback. Authored by caniselfhostit
# from the upstream documentation, not copied from a repository:
#   run with docker .... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docs/wiki/2.13-Run-with-Docker.md
#   entrypoint ......... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docker-entrypoint.sh
#   nginx template ..... https://github.com/super-productivity/super-productivity/blob/v18.19.0/nginx/default.conf.template
#
# One service: nginx serving the built web app as static files. No database, no
# volume, no state on this box, because upstream states that data is stored in
# the browser and the container provides no persistent storage. No env_file and
# no environment block, on purpose: the entrypoint would write a world-readable
# sync-config-default-override.json under assets/ from the WEBDAV_ and
# SYNC_INTERVAL variables, and with WEBDAV_BACKEND unset the image's /webdav/
# proxy answers 404. Block 8 of the prompts says why. No
# `user:` line: nginx binds 80 in the container and the image names no
# unprivileged user; the healthcheck uses the curl the image installs.
#
# Image identity, checked 2026-08-14: the wiki's docker run line names a Docker
# Hub repository that does not resolve; the one that does, and that upstream's
# own compose file names, is johannesjo/super-productivity. Tag v18.19.0 was
# released 2026-08-07; digest read from registry-1.docker.io on 2026-08-14, an
# OCI index covering linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  super-productivity:
    image: johannesjo/super-productivity:v18.19.0@sha256:ae91fe9ac19561e0f3669d15a2c4c71d7a75c43a29eb44ddc010ae50d1f63c82
    container_name: super-productivity
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8199.
      - "127.0.0.1:8199:80"
```

## compose.local.yml

```yaml
# Super Productivity · the deterministic fallback for the local path. Authored
# by caniselfhostit from the upstream documentation, not copied from a
# repository:
#   run with docker .... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docs/wiki/2.13-Run-with-Docker.md
#   image build ........ https://github.com/super-productivity/super-productivity/blob/v18.19.0/Dockerfile
#   entrypoint ......... https://github.com/super-productivity/super-productivity/blob/v18.19.0/docker-entrypoint.sh
#
# One service on the computer you are sitting at. No volumes and no bind mounts,
# because there is nothing to mount: the image is nginx serving the built web
# app as static files, and every task you type is stored by the browser under
# http://localhost:8199, not by this container. That is also why there is no
# .env and no environment block. Browsers treat http://localhost as a secure
# context, so the parts of the app that need WebCrypto keep working without a
# certificate.
#
# Tag v18.19.0 was released 2026-08-07; digest read from registry-1.docker.io on
# 2026-08-14, an OCI index covering linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  super-productivity:
    image: johannesjo/super-productivity:v18.19.0@sha256:ae91fe9ac19561e0f3669d15a2c4c71d7a75c43a29eb44ddc010ae50d1f63c82
    container_name: super-productivity
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost/"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    ports:
      # Loopback only: no other device on the wifi can reach 8199.
      - "127.0.0.1:8199:80"
```

## Caddyfile

```text
# Super Productivity · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# 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. What is served here is
# public by design: there are no accounts, because there is no server-side store
# to have an account in, and a stranger loading this URL gets an empty task list
# in their own browser rather than a view of yours. Caddy basic_auth is the
# opt-in. TLS is not decoration here: the app registers a service worker and
# uses WebCrypto, and browsers grant neither on a plain-http hostname.

<DOMAIN> {
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8199 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:8199
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Super Productivity · 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=tasks.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/super-productivity/super-productivity/blob/v18.19.0/docs/wiki/2.13-Run-with-Docker.md
#
# No secret is generated and no .env is written. There are no accounts, no
# registration form and no first-run wizard, because there is no server-side
# store to have an account in: upstream states that data is stored in the
# browser and the container provides no persistent storage. The page is public
# by design, and a stranger who loads it gets an empty task list in their own
# browser rather than a window into yours. Caddy basic_auth is the opt-in if
# you would rather the URL were not world-readable.
#
# There is no application data directory. This script's backup is compose.yml
# plus the live Caddyfile. YOUR backup is the app's own Export data file, and
# the summary at the end tells you where to click for it.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/super-productivity}"
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. tasks.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."

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 256 ] || die "only ${avail_mb} MB of RAM available; this install wants 256 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 2 ] || die "only ${avail_gb} GB free on /srv; this install wants 2 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}')" || resolved=""
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# No data/ directory. The container mounts nothing and writes nothing you
# would want back.

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

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

# --- 3. 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-super-productivity"
	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

# --- 4. Ports ----------------------------------------------------------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8199 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 5. Start it -------------------------------------------------------------

docker compose pull
docker compose up -d

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

# --- 6. Asserts, with their evidence printed ---------------------------------

title_hits="$(curl -sSL "https://${DOMAIN_HOST}/" | grep -c '<title>Super Productivity</title>')" || title_hits=0
echo "==> title marker count: ${title_hits}"
[ "$title_hits" -ge 1 ] || die "the page at https://${DOMAIN_HOST}/ does not look like Super Productivity"

echo "==> served sync defaults:"
override="$(curl -sS "https://${DOMAIN_HOST}/assets/sync-config-default-override.json")"
printf '%s\n' "$override"
leaks="$(printf '%s' "$override" | grep -ciE 'password|userName|baseUrl|syncFolderPath')" || leaks=0
echo "==> credential-shaped keys in that file: ${leaks}"
[ "$leaks" -eq 0 ] || die "the served sync defaults name a server or an account. This install sets no WEBDAV_ variables, so that file should carry nothing but its comment."

dav_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/webdav/")" || dav_code=""
echo "==> /webdav/ answered: ${dav_code}"
[ "$dav_code" = "404" ] || die "/webdav/ answered ${dav_code:-nothing}; with WEBDAV_BACKEND unset the image's proxy route must answer 404"

# --- 7. Backup (compose + live Caddyfile; there is no app state on this box) --

STAMP="$(date +%Y%m%d-%H%M%S)"
sudo tar -czf "$APP_DIR/backups/super-productivity-${STAMP}.tar.gz" \
	-C "$APP_DIR" compose.yml \
	-C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/super-productivity-${STAMP}.tar.gz" ] || die "the backup archive is empty"

cat <<-DONE

	Super Productivity is answering at https://${DOMAIN_HOST}

	  1. Open it and add one task. That task is now in this browser and nowhere
	     else. The server holds nothing, which is why the archive above is two
	     files of configuration and not a database.
	  2. Take your real backup now, in the app: Settings, the Sync & Backup tab,
	     then Export data. That downloads one plaintext JSON file holding tasks,
	     projects, tags, time tracking, notes and archives. Import in the same
	     place puts it back. Keep the file somewhere that is not this laptop,
	     and treat it as private: it is not encrypted and it can carry the API
	     credentials of any issue provider you connect later.
	  3. That page is public. There is no login, because there is no account.
	     Anyone who reaches the hostname gets their own empty list, not yours.
	     Caddy basic_auth is the opt-in if you want a password in front.
	  4. Cross-device sync is not configured and is not one setting away. Read
	     block 8 of prompt.md before you try: upstream says browser WebDAV sync
	     is likely to fail on CORS, and the image's own same-origin workaround
	     caps uploads at 1 MB and sends no TLS server name upstream.
	  5. Copy $APP_DIR/backups off this disk, and copy the exported JSON off it
	     too. NOT YET VERIFIED on a clean harness machine.

DONE
```

## Also evaluated

Ranked below Super Productivity for this swap. The prompts above install Super Productivity only.

- **Vikunja** — One container holding every task, project and due date, with no project cap and no per-seat bill. The one to pick if what you want back is a list with a server behind it: one container, an embedded database, real accounts, and the same tasks on every device without any browser trickery. It keeps the top spot on this catalogue's Todoist page, and it sits second here because TickTick's daily loop is a timer and a calendar as much as a list, and Vikunja has no Pomodoro timer and puts time tracking behind a paid key. Reminders still need a mail server or a webhook before anything chases you.

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