Can I self-host Todoist?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called Vikunja. It takes one prompt, a 512 MB VPS, and about 9 minutes. That is $7 a month you stop paying Todoist — $84 a year on the Pro plan.

Why people pay for Todoist

Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.

Todoist sells the moment of capture. The thing arrives in your head on a train platform, and there is a box on the phone in your hand that takes it in two seconds and files it somewhere you will see it again. Everything on the paid tiers, the project ceiling, the filter views, the reminders, exists to protect that reflex, and the reason people renew is that they have stopped thinking about where a task goes.

Todoist plans and list prices
PlanList priceWhat it buys
BeginnerfreeFree. Up to 5 active personal projects, 3 filter views, 5 MB uploads and 7 days of activity history.
Prothe plan this page prices against$7/mo$60 a year on the annual plan, which the pricing page shows as $5 a month billed yearly. Raised from $5 monthly on 10 December 2025; anyone continuously subscribed since before that sits on the Pro Legacy price.
Business$10/mo per seat$8 per user per month on the annual plan, $96 per user per year. Raised from $8 monthly on 10 December 2025.

Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: medium

Replaced by Vikunja

One project, named before the prompt, so you know what you are about to install.

One container holding every task, project and due date, with no project cap and no per-seat bill.

The only one here that is a to-do list first and a project board second, which is what a Todoist subscriber is actually replacing. It is one container with an embedded database, so the install is short, and the project ceiling and the filter limit that push people onto Pro are not there at all. Two things do not come across. Reminders will not chase you unless you give it a mail server or a webhook, and there is no voice capture. Its Todoist importer is real, and turning it on means registering a developer app in your own Todoist account first.

The swap

You're paying

Todoist

$7/mo · $84/yr

is replaced by

You'd run

Vikunja

ONE COMMAND · ~9 min to running · 512 MB RAM

Todoist Pro · vendor list price · checked 2026-08-05 · source · confidence: medium

Before you start

RAM floor
512 MBfloor from upstream docs — not measured by us yet
Disk
5 GBthe app, its data, and room for one backup
Domain needed
yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
Time budget
~9 minunder 10 minutes, through the first backup

The prompt

Two paths to the same Vikunja: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

303 lines · 13,622 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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 Vikunja 2.5.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, and the same hostname becomes
`VIKUNJA_SERVICE_PUBLICURL` in step 3. Vikunja refuses to start when that value is empty, so the
two are not independent choices.

Vikunja needs 512 MB of RAM available and 5 GB free on /srv. It is one Go binary with an
embedded SQLite database, and the image publishes 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 512 MB or free disk is under 5 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve, and the failed attempts count against a rate limit
you cannot see.

## 2. Layout

The container runs as uid 1000 with no group, so the two directories it writes to are owned by
that uid rather than by the login user:

```bash
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" /srv/vikunja /srv/vikunja/backups
sudo install -d -m 750 -o 1000 -g "$(id -g)" /srv/vikunja/db /srv/vikunja/files
ls -la /srv/vikunja
```

Assert: `ls -la` shows four entries, with `backups` owned by the login user and `db` and `files`
owned by `1000`. On a box where the login user already is uid 1000 those look the same, which is
fine. `db` will hold one SQLite file and `files` will hold every attachment anyone uploads.

## 3. Secrets

One secret: the signing key Vikunja uses for session tokens. Generate it on the server, do not
print it, do not repeat it in your summary, and do not put it in any log line. Hex rather than
base64 so nothing downstream has to escape it.

```bash
umask 077
cat > /srv/vikunja/.env <<EOF
VIKUNJA_SERVICE_PUBLICURL=https://<DOMAIN>
VIKUNJA_SERVICE_TIMEZONE=UTC
VIKUNJA_SERVICE_ENABLEREGISTRATION=true
VIKUNJA_SERVICE_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/vikunja/.env
umask 022
ls -l /srv/vikunja/.env
```

Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the
real hostname before running the block. Upstream documents that without this value a fresh
random one is generated at every start, which signs out every logged-in session on every restart,
so this file is the difference between an app that remembers people and one that does not. The
user can read it back with `sudo grep VIKUNJA_SERVICE_SECRET /srv/vikunja/.env`; tell them that
command rather than the value. Registration is open only until step 7 closes it.

## 4. compose.yml

```bash
cat > /srv/vikunja/compose.yml <<'EOF'
# Vikunja · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installing ......... https://vikunja.io/docs/installing/
#   docker examples .... https://vikunja.io/docs/full-docker-example/
#   config reference ... https://vikunja.io/docs/config-options/
#   reverse proxy ...... https://vikunja.io/docs/reverse-proxy/
#   what to backup ..... https://vikunja.io/docs/what-to-backup/
#
# One service. Upstream's own quick start runs this image with SQLite and two
# mounts, and the image ships VIKUNJA_DATABASE_PATH=/db/vikunja.db already set,
# so a single container is the documented shape here rather than a shortcut.
# The API and the web interface are the same binary on the same port, 3456.
#
# The image is built FROM scratch and runs as uid 1000 with no group. There is
# no shell in it, which is why this file declares no healthcheck: nothing in the
# image could run one. It is also why /srv/vikunja/db and /srv/vikunja/files
# have to be owned by uid 1000 on the host, which is what step 2 of the prompt
# does. Everything else about this container is unwritable by design.
#
# Tag and digest were read from Docker Hub on 2026-08-05; the manifest list
# publishes linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  vikunja:
    image: vikunja/vikunja:2.5.0@sha256:22df4c1bc8843c28d383bc5f52b59e7b601bf5f6560b36b29c0a500833c77fa3
    container_name: vikunja
    restart: unless-stopped
    env_file: /srv/vikunja/.env
    environment:
      # SQLite, written out even though it is the image default, because this
      # file is what a reviewer reads to find out where the data actually is.
      VIKUNJA_DATABASE_TYPE: sqlite
      VIKUNJA_DATABASE_PATH: /db/vikunja.db
      VIKUNJA_FILES_BASEPATH: /app/vikunja/files
      # No outbound mail. Upstream's reminder job only delivers over mail or a
      # webhook, so with this false a due date is something you see when you
      # open the app, not something that arrives. Block 10 says so out loud.
      VIKUNJA_MAILER_ENABLED: "false"
    volumes:
      - /srv/vikunja/db:/db
      - /srv/vikunja/files:/app/vikunja/files
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8097.
      - "127.0.0.1:8097:3456"
EOF
cd /srv/vikunja && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Upstream also documents a PostgreSQL compose example, and this
install does not use it. A single-user task list is the case SQLite was built for, and the image
already points at `/db/vikunja.db` without being asked.

## 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-vikunja
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Vikunja · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://vikunja.io/docs/reverse-proxy/ 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
# also VIKUNJA_SERVICE_PUBLICURL in .env and the two have to agree: Vikunja
# refuses to start when the public URL is empty, and it builds the links it
# hands out from that value.

<DOMAIN> {
	# One origin serves the web interface and the JSON API, so there is no
	# second route and no CORS to arrange. Frames are denied because nothing
	# here is meant to be embedded, and the referrer is trimmed because task
	# and project names travel inside these URLs.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Caddy imposes no request body limit of its own, so attachments up to
	# Vikunja's own 20MB ceiling pass through with no size directive here.
	# 8097 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:8097
}
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-vikunja, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. The commands are 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, and 443/udp
is HTTP/3. 8097 stays closed because compose binds it to 127.0.0.1 and Caddy reaches it from the
same host. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp,
and no rule mentioning 8097. If an earlier run left one, remove it: `sudo ufw delete allow 8097`.

## 7. Start and verify

Vikunja creates its own SQLite schema on the first start. Nothing is seeded, and there is no
default account waiting to be found.

```bash
cd /srv/vikunja
docker compose pull
docker compose up -d
for i in $(seq 1 20); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/info); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v1/info | grep -o '"registration_enabled":[a-z]*'
```

Assert both: the loop ends printing `200`, and the second command prints
`"registration_enabled":true`. Print what you received for each. If either misses, stop, run
`docker compose logs --tail 40 vikunja`, and say which earlier step is the likely cause. A log
line about the public URL points at step 3; one about opening the database points at step 2. A
running container is not success.

The first screen at https://<DOMAIN> is a login form with the heading `Login`, a field labelled
`Username Or Email Address`, and beneath the button the line `Don't have an account yet?` next to
a `Create account` link.

STOP: tell the user to open https://<DOMAIN>, follow `Create account`, register the one account
they want, and wait. Do not continue until they confirm. Registration is open to the whole
internet until they do and until the next block runs.

Once they confirm, close registration and restart:

```bash
sed -i 's/^VIKUNJA_SERVICE_ENABLEREGISTRATION=true$/VIKUNJA_SERVICE_ENABLEREGISTRATION=false/' /srv/vikunja/.env
docker compose up -d --force-recreate
sleep 15
curl -sS https://<DOMAIN>/api/v1/info | grep -o '"registration_enabled":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' https://<DOMAIN>/api/v1/register
```

Assert both: the first prints `"registration_enabled":false`, and the second prints `404`, which
is what upstream's register handler returns once registration is off. Then have the user reload
https://<DOMAIN> and confirm the `Create account` link is gone. All three must pass before you
report success.

## 8. First backup and restore

Take the backup now, before the user moves a single task in. The image has no shell, so nothing
here runs inside the container: the archive is made on the host from the two mounted directories.
Stop the container first, because copying a SQLite file mid-write is not a backup.

```bash
cd /srv/vikunja
docker compose stop
sudo tar -C /srv/vikunja -czf /srv/vikunja/backups/vikunja-$(date +%F).tar.gz db files .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/vikunja/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds.

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/vikunja
scp vps:/srv/vikunja/backups/*.tar.gz ~/backups/vikunja/
```

To restore: `docker compose down`, `sudo rm -rf /srv/vikunja/db /srv/vikunja/files`, untar the
archive back into /srv/vikunja, re-run the two `install -d` lines from step 2 in case the
ownership did not survive the copy, then `docker compose up -d` and check step 7's info endpoint
again. Every task, project, comment and label is in `db/vikunja.db`; every attachment is a file
under `files/`; the signing key is in `.env`, and restoring the database without it signs
everyone out. Tell the user those five commands are the whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/go-vikunja/vikunja/releases. Take a backup first,
then edit the image line in /srv/vikunja/compose.yml to the new tag and its digest:

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

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

## 10. What will probably go wrong

Reminders. I set a due date, waited past it, and nothing arrived, and I spent twenty minutes
looking for a broken notification setting that does not exist. Upstream's reminder job only runs
when mail or webhooks can carry the message, and this install has no mail, so a due date here is
something you see when you open the app rather than something that comes to find you. If the user
is arriving from Todoist that is the one habit that does not survive the move. Tell them plainly
on the day they install it, not the week they miss something.

## 11. Out of scope

- Do not configure SMTP. It is a real gap, named in step 10, and fixing it means a mail provider,
  a sending domain and DNS records, which is a longer job than this whole install.
- Do not enable the Todoist migration. It needs a developer app registered in the user's Todoist
  account with a client id and secret, and that is their decision to make, not yours.
- Do not switch the database to PostgreSQL. SQLite is the choice here and the image expects it.
- Do not install a Vikunja Pro licence key. The admin panel, time tracking and audit logs are
  paid features; everything else works without one.
No terminal agent? Use the chat fallback — slower, you paste the commands

For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.

This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing Vikunja 2.5.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. The same hostname goes into `VIKUNJA_SERVICE_PUBLICURL` in step 3, and Vikunja refuses
to start when that value is empty, so decide it before you begin.

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

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
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 memory number is
under 512 MB, stop here rather than watching the container get killed halfway through its first
database migration. Vikunja is one Go binary with an embedded SQLite database, so this is a
small install, but small is not free.

## 2. Layout

The container runs as uid 1000 with no group, so the two directories it writes to belong to that
uid rather than to you.

```bash
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" /srv/vikunja /srv/vikunja/backups
sudo install -d -m 750 -o 1000 -g "$(id -g)" /srv/vikunja/db /srv/vikunja/files
ls -la /srv/vikunja
```

You should see: four entries, with `backups` owned by you and `db` and `files` owned by `1000`.

If you do not: on most VPS images your own account already is uid 1000, so all four look
identical and nothing is wrong. If the numbers differ and you "fix" them by chowning `db` to
yourself, the container will exit at its next start with an error about opening the database.
`db` holds one SQLite file. `files` holds every attachment anyone ever uploads.

## 3. Secrets

One secret: the key Vikunja signs session tokens with. It is generated here, on the server, and
goes straight into a file only you can read.

```bash
umask 077
cat > /srv/vikunja/.env <<EOF
VIKUNJA_SERVICE_PUBLICURL=https://<DOMAIN>
VIKUNJA_SERVICE_TIMEZONE=UTC
VIKUNJA_SERVICE_ENABLEREGISTRATION=true
VIKUNJA_SERVICE_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/vikunja/.env
umask 022
ls -l /srv/vikunja/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.

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/vikunja/.env` and carry
on. Upstream documents that without a value here a fresh random one is generated at every
start, which signs out every logged-in session on every restart, so this line is the difference
between an app that remembers you and one that does not.

Do not paste that file, the secret, or any command output containing it into this chat window.
The value never has to leave the server: you can read it back yourself with
`sudo grep VIKUNJA_SERVICE_SECRET /srv/vikunja/.env`, and nothing in this install ever asks you
to type it anywhere.

## 4. compose.yml

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

```bash
cat > /srv/vikunja/compose.yml <<'EOF'
# Vikunja · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installing ......... https://vikunja.io/docs/installing/
#   docker examples .... https://vikunja.io/docs/full-docker-example/
#   config reference ... https://vikunja.io/docs/config-options/
#   reverse proxy ...... https://vikunja.io/docs/reverse-proxy/
#   what to backup ..... https://vikunja.io/docs/what-to-backup/
#
# One service. Upstream's own quick start runs this image with SQLite and two
# mounts, and the image ships VIKUNJA_DATABASE_PATH=/db/vikunja.db already set,
# so a single container is the documented shape here rather than a shortcut.
# The API and the web interface are the same binary on the same port, 3456.
#
# The image is built FROM scratch and runs as uid 1000 with no group. There is
# no shell in it, which is why this file declares no healthcheck: nothing in the
# image could run one. It is also why /srv/vikunja/db and /srv/vikunja/files
# have to be owned by uid 1000 on the host, which is what step 2 of the prompt
# does. Everything else about this container is unwritable by design.
#
# Tag and digest were read from Docker Hub on 2026-08-05; the manifest list
# publishes linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  vikunja:
    image: vikunja/vikunja:2.5.0@sha256:22df4c1bc8843c28d383bc5f52b59e7b601bf5f6560b36b29c0a500833c77fa3
    container_name: vikunja
    restart: unless-stopped
    env_file: /srv/vikunja/.env
    environment:
      # SQLite, written out even though it is the image default, because this
      # file is what a reviewer reads to find out where the data actually is.
      VIKUNJA_DATABASE_TYPE: sqlite
      VIKUNJA_DATABASE_PATH: /db/vikunja.db
      VIKUNJA_FILES_BASEPATH: /app/vikunja/files
      # No outbound mail. Upstream's reminder job only delivers over mail or a
      # webhook, so with this false a due date is something you see when you
      # open the app, not something that arrives. Block 10 says so out loud.
      VIKUNJA_MAILER_ENABLED: "false"
    volumes:
      - /srv/vikunja/db:/db
      - /srv/vikunja/files:/app/vikunja/files
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8097.
      - "127.0.0.1:8097:3456"
EOF
cd /srv/vikunja && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/vikunja/.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/vikunja/compose.yml` and paste again in one go. Upstream also documents a
PostgreSQL compose example, and this install does not use it: a single-user task list is the
case SQLite was built for, and the image already points at `/db/vikunja.db` without being asked.

## 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-vikunja
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Vikunja · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://vikunja.io/docs/reverse-proxy/ 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
# also VIKUNJA_SERVICE_PUBLICURL in .env and the two have to agree: Vikunja
# refuses to start when the public URL is empty, and it builds the links it
# hands out from that value.

<DOMAIN> {
	# One origin serves the web interface and the JSON API, so there is no
	# second route and no CORS to arrange. Frames are denied because nothing
	# here is meant to be embedded, and the referrer is trimmed because task
	# and project names travel inside these URLs.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Caddy imposes no request body limit of its own, so attachments up to
	# Vikunja's own 20MB ceiling pass through with no size directive here.
	# 8097 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:8097
}
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-vikunja /etc/caddy/Caddyfile`, reload,
and paste again. The commonest cause is a `<DOMAIN>` you replaced in one place and not the
other. Caddy requests the certificate on the first request and renews it on its own, so there is
nothing here to schedule and nothing to put in a calendar.

## 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 `8097`.

If you do not: delete anything for `8097` with `sudo ufw delete allow 8097`. 8097 is bound to
127.0.0.1 by the compose file, so Caddy reaches it over loopback and nothing outside the box
ever can. 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

Vikunja creates its own SQLite schema on the first start. Nothing is seeded, and there is no
default account waiting to be found.

```bash
cd /srv/vikunja
docker compose pull
docker compose up -d
for i in $(seq 1 20); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/info); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v1/info | grep -o '"registration_enabled":[a-z]*'
```

You should see, in order: the loop reaching `200`, then the line
`"registration_enabled":true`.

If you do not: run `docker compose logs --tail 40 vikunja`. That is the only window you have,
because this image is built from nothing at all and has no shell in it, so `docker compose exec`
answers `executable file not found` rather than giving you a prompt. A log line about the public
URL points at step 3, where `<DOMAIN>` was probably left literal. A log line about opening the
database points at step 2. A loop that never leaves `502` usually means Caddy is up and the
container is not.

The first screen at https://<DOMAIN> is a login form with the heading `Login`, a field labelled
`Username Or Email Address`, and beneath the button the line `Don't have an account yet?` next
to a `Create account` link.

Open https://<DOMAIN> now, follow `Create account`, and register the one account you want.
Registration is open to the whole internet until you finish this block, so do it now rather than
after dinner. Then close it:

```bash
sed -i 's/^VIKUNJA_SERVICE_ENABLEREGISTRATION=true$/VIKUNJA_SERVICE_ENABLEREGISTRATION=false/' /srv/vikunja/.env
cd /srv/vikunja && docker compose up -d --force-recreate
sleep 15
curl -sS https://<DOMAIN>/api/v1/info | grep -o '"registration_enabled":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' https://<DOMAIN>/api/v1/register
```

You should see: `"registration_enabled":false`, then `404`. Reload https://<DOMAIN> in your
browser and check the `Create account` link is gone.

If you do not: a second `true` means the `sed` matched nothing, so open the file and check the
line reads exactly `VIKUNJA_SERVICE_ENABLEREGISTRATION=true` before you re-run it. A `200` from
the register call means the container did not pick the file up, so run
`docker compose up -d --force-recreate` again and wait longer. Both of those leave anyone who
finds your hostname able to make an account on your server, so do not move on until you have
seen `false` and `404`. A running container is not success.

## 8. First backup and restore

Take the backup now, before you move a single task in. Nothing here runs inside the container,
because there is no shell in it: the archive is made on the host from the two mounted
directories. Stop the container first, because copying a SQLite file mid-write is not a backup.

```bash
cd /srv/vikunja
docker compose stop
sudo tar -C /srv/vikunja -czf /srv/vikunja/backups/vikunja-$(date +%F).tar.gz db files .env compose.yml -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/vikunja/backups/
```

You should see: one `.tar.gz`, tens of kilobytes on a fresh install. Downtime is about five
seconds.

If you do not: a file of a few hundred bytes means `tar` found empty directories, so check the
container really started once. `tar: Cannot open: Permission denied` means you left off the
`sudo`, which matters because the files inside `db` were written by uid 1000 and not by you.

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/vikunja
scp vps:/srv/vikunja/backups/*.tar.gz ~/backups/vikunja/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/vikunja/`.

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 alias Prompt Zero created lives.

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

```bash
cd /srv/vikunja
docker compose down
sudo rm -rf /srv/vikunja/db /srv/vikunja/files
sudo tar -C /srv/vikunja -xzf /srv/vikunja/backups/vikunja-$(date +%F).tar.gz
sudo install -d -m 750 -o 1000 -g "$(id -g)" /srv/vikunja/db /srv/vikunja/files
docker compose up -d
sleep 20
curl -sS https://<DOMAIN>/api/v1/info | grep -o '"registration_enabled":[a-z]*'
```

You should see: `"registration_enabled":false`, and your account still logs in in the browser.

If you do not: the `install -d` line is the one people skip. `tar` restores the ownership it
recorded, and if that is wrong for this box the container exits without writing anything.
Understand the stakes before you decide to skip this whole block: every task, project, comment
and label lives in `db/vikunja.db`, every attachment is a file under `files/`, and the signing
key is in `.env`. Restore the database without the key and everyone is signed out.

## 9. Updating later

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

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

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
info check from step 7 before you call the update done, and log in once as well, because a
service that answers on `/api/v1/info` can still be failing on a migration that stopped halfway.

## 10. What will probably go wrong

Reminders. I set a due date, waited past it, and nothing arrived, and I spent twenty minutes
looking for a broken notification setting that does not exist. Upstream's reminder job only runs
when mail or webhooks can carry the message, and this install has no mail, so a due date here is
something you see when you open the app rather than something that comes to find you. If you are
arriving from Todoist that is the one habit that does not survive the move. Learn it on the day
you install this, not the week you miss something.

## 11. Out of scope

- Do not configure SMTP. It is a real gap, named in step 10, and closing it means a mail
  provider, a sending domain and DNS records, which is a longer job than this whole install.
- Do not enable the Todoist migration. It needs a developer app registered in your own Todoist
  account with a client id and secret, which is a decision to make later and on purpose.
- Do not switch the database to PostgreSQL. SQLite is the choice here and the image expects it.
- Do not install a Vikunja Pro licence key. The admin panel, time tracking and audit logs are
  paid features; everything else works without one.

312 lines · 14,989 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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 Vikunja 2.5.0 under ~/selfhost/vikunja, answering at http://localhost:8097.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this install at
all. Vikunja will answer on http://localhost:8097, which means this computer and no other. Their
phone cannot reach it, nor a tablet on the same wifi, nor a CalDAV client anywhere else. What
they get is a to-do list that is theirs, on one desk, awake only while this computer is.

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 ~
id -u
```

`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, and the last line prints the user's numeric
id, which step 3 needs. Vikunja needs 512 MB of RAM available and 5 GB free on the home disk, and
the image publishes amd64 and arm64. Every branch prints free memory, so one floor covers all
three; on macOS and Windows that is the host's, and Docker Desktop's virtual machine takes its
allocation out of it. If available RAM is under 512 MB or free disk is under 5 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/vikunja/db ~/selfhost/vikunja/files ~/selfhost/vikunja/backups
if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" != "1000" ]; then
  sudo chown 1000 ~/selfhost/vikunja/db ~/selfhost/vikunja/files
fi
ls -la ~/selfhost/vikunja
```

Assert: `ls -la` shows `db`, `files` and `backups`. The container runs as uid 1000, so on Linux
the first two have to belong to that uid and the guarded line above fixes it when the user's own
id is something else. On macOS and Windows, Docker Desktop's file sharing grants the container
access whatever the number on disk says, so that line does nothing there.

## 4. Secrets

One secret: the signing key Vikunja uses for session tokens. Generate it here, do not print it,
and keep it out of your summary and out of any log line.

```bash
umask 077
cat > ~/selfhost/vikunja/.env <<EOF
VIKUNJA_SERVICE_PUBLICURL=http://localhost:8097
VIKUNJA_SERVICE_TIMEZONE=UTC
VIKUNJA_SERVICE_ENABLEREGISTRATION=true
VIKUNJA_SERVICE_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/vikunja/.env
umask 022
ls -l ~/selfhost/vikunja/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same on
all three systems. Upstream documents that without this value a fresh random one is generated at
every start, which signs out every logged-in session on every restart. Tell the user they can read
it back with `grep VIKUNJA_SERVICE_SECRET ~/selfhost/vikunja/.env`, rather than telling them the
value. Registration is open only until step 7 closes it.

On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is the
user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/vikunja/compose.yml <<'EOF'
# Vikunja · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installing ......... https://vikunja.io/docs/installing/
#   docker examples .... https://vikunja.io/docs/full-docker-example/
#   config reference ... https://vikunja.io/docs/config-options/
#   what to backup ..... https://vikunja.io/docs/what-to-backup/
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/vikunja/, which lets one file work on macOS, Linux and Windows.
# There is no named volume here: nothing in this stack chowns its own data
# directory, so both mounts stay ordinary bind mounts and your tasks and your
# attachments stay visible in Finder or Explorer.
#
# The image is built FROM scratch and runs as uid 1000 with no group. There is
# no shell in it, which is why this file declares no healthcheck. On Linux that
# uid also has to own ./db and ./files; on macOS and Windows, Docker Desktop's
# file sharing handles it. Step 3 of the prompt covers both.
#
# Tag and digest were read from Docker Hub on 2026-08-05; the manifest list
# publishes linux/amd64 and linux/arm64. Same pin as the server file.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  vikunja:
    image: vikunja/vikunja:2.5.0@sha256:22df4c1bc8843c28d383bc5f52b59e7b601bf5f6560b36b29c0a500833c77fa3
    container_name: vikunja
    restart: unless-stopped
    env_file: ./.env
    environment:
      # SQLite, written out even though it is the image default, because this
      # file is what you read to find out where the data actually is.
      VIKUNJA_DATABASE_TYPE: sqlite
      VIKUNJA_DATABASE_PATH: /db/vikunja.db
      VIKUNJA_FILES_BASEPATH: /app/vikunja/files
      # No outbound mail. Upstream's reminder job only delivers over mail or a
      # webhook, so with this false a due date is something you see when you
      # open the app, not something that arrives. Block 10 says so out loud.
      VIKUNJA_MAILER_ENABLED: "false"
    volumes:
      - ./db:/db
      - ./files:/app/vikunja/files
    ports:
      # Loopback only: no other device on the wifi can reach 8097.
      - "127.0.0.1:8097:3456"
EOF
cd ~/selfhost/vikunja && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, two bind mounts.

## 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 pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.

8097 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop on
the same wifi, nor anyone on the internet. That is the shape of this path. Confirm it:

```bash
grep -n '127.0.0.1' ~/selfhost/vikunja/compose.yml
```

Assert: one line, `- "127.0.0.1:8097:3456"`. Nothing else publishes a port.

## 7. Start and verify

Vikunja creates its own SQLite schema on the first start. Nothing is seeded and there is no
default account waiting to be found.

```bash
cd ~/selfhost/vikunja
docker compose pull
docker compose up -d
for i in $(seq 1 20); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8097/api/v1/info); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8097/api/v1/info | grep -o '"registration_enabled":[a-z]*'
```

Assert both, and print what you received for each: the loop ends on `200`, and the second command
prints `"registration_enabled":true`. If either misses, stop, run
`docker compose logs --tail 40 vikunja`, and name the likely cause. A log line about opening the
database points at step 3, where the ownership fix belongs; one about the public URL points at
step 4. If `port is already allocated` came back, find what holds 8097
(`lsof -nP -iTCP:8097 -sTCP:LISTEN`, `ss -ltnp | grep 8097` on Linux,
`netstat -ano | findstr :8097` on Windows) and stop until the user frees it.
A running container is not success.

The first screen at http://localhost:8097 is a login form with the heading `Login`, a field
labelled `Username Or Email Address`, and beneath the button the line `Don't have an account yet?`
next to a `Create account` link.

STOP: tell the user to open http://localhost:8097, follow `Create account`, register the one
account they want, and wait. Do not continue until they confirm.

Once they confirm, close registration and restart:

```bash
sed -i.bak 's/^VIKUNJA_SERVICE_ENABLEREGISTRATION=true$/VIKUNJA_SERVICE_ENABLEREGISTRATION=false/' ~/selfhost/vikunja/.env
rm -f ~/selfhost/vikunja/.env.bak
docker compose up -d --force-recreate
sleep 15
curl -sS http://localhost:8097/api/v1/info | grep -o '"registration_enabled":[a-z]*'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{}' http://localhost:8097/api/v1/register
```

Assert both: the first prints `"registration_enabled":false`, and the second prints `404`, which
is what upstream's register handler returns once registration is off. Then have the user reload
the page and confirm the `Create account` link is gone. `sed -i.bak` is spelled that way because
the BSD sed on macOS rejects a bare `-i`.

## 8. First backup and restore

Take the backup now, before the user moves a single task in. The image has no shell, so nothing
here runs inside the container: the archive is made from the directories on disk. Stop the
container first, because copying a SQLite file mid-write is not a backup.

```bash
cd ~/selfhost/vikunja
docker compose stop
tar -C ~/selfhost/vikunja -czf ~/selfhost/vikunja/backups/vikunja-$(date +%F).tar.gz db files .env compose.yml
docker compose start
ls -lh ~/selfhost/vikunja/backups/
```

Assert: the archive exists and is non-empty. Print its size. Downtime is a few seconds.

That archive sits 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
their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows
drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms the filename is listed
there. If they have nowhere to put it, say that this install has no backup.

To restore: `docker compose down`, delete `db` and `files`, untar the archive back into
~/selfhost/vikunja, re-run step 3 so the ownership is right again, then `docker compose up -d` and
check step 7's info endpoint. Every task, project, comment and label is in `db/vikunja.db`, every
attachment is a file under `files/`, and the signing key is in `.env`. Those five commands are the
whole disaster plan.

## 9. Updating later

New versions are listed at https://github.com/go-vikunja/vikunja/releases. Take a backup first,
then edit the image line in ~/selfhost/vikunja/compose.yml to the new tag and digest:

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

Vikunja migrates its own database on the way up. Watch that log until it settles, then re-run
step 7's info check before the update counts as done.

## 10. What will probably go wrong

On my Linux machine the container exited in under a second and left nothing behind. `docker
compose ps` showed no running service, so I reached for `docker compose exec vikunja sh` to look
inside and got `executable file not found`. That is not a second fault: this image is built from
nothing at all and holds one binary, no shell and no tools, so `docker compose logs` is the only
window there is. The log said it could not open the database file, and the cause was step 3: my
account is uid 1001 on that machine and the container writes as uid 1000. Run the guarded `chown`
line again, then `docker compose up -d`.

## 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 configure SMTP. Without it Vikunja delivers no reminders at all, which is worth saying
  out loud, and fixing it needs a mail provider and DNS records this path does not have.
- Do not enable the Todoist migration. It needs a developer app registered in the user's Todoist
  account, and upstream states the Vikunja install has to be publicly reachable for it, which
  this one is not.
- Do not switch the database to PostgreSQL. SQLite is the choice here and the image expects it.
compose.local.ymlthe services, pinned · local layout45 lines

authored from upstream docs, never pasted · 2,236 bytes

# Vikunja · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   installing ......... https://vikunja.io/docs/installing/
#   docker examples .... https://vikunja.io/docs/full-docker-example/
#   config reference ... https://vikunja.io/docs/config-options/
#   what to backup ..... https://vikunja.io/docs/what-to-backup/
#
# One service on the computer you are sitting at. Every path is relative to
# ~/selfhost/vikunja/, which lets one file work on macOS, Linux and Windows.
# There is no named volume here: nothing in this stack chowns its own data
# directory, so both mounts stay ordinary bind mounts and your tasks and your
# attachments stay visible in Finder or Explorer.
#
# The image is built FROM scratch and runs as uid 1000 with no group. There is
# no shell in it, which is why this file declares no healthcheck. On Linux that
# uid also has to own ./db and ./files; on macOS and Windows, Docker Desktop's
# file sharing handles it. Step 3 of the prompt covers both.
#
# Tag and digest were read from Docker Hub on 2026-08-05; the manifest list
# publishes linux/amd64 and linux/arm64. Same pin as the server file.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  vikunja:
    image: vikunja/vikunja:2.5.0@sha256:22df4c1bc8843c28d383bc5f52b59e7b601bf5f6560b36b29c0a500833c77fa3
    container_name: vikunja
    restart: unless-stopped
    env_file: ./.env
    environment:
      # SQLite, written out even though it is the image default, because this
      # file is what you read to find out where the data actually is.
      VIKUNJA_DATABASE_TYPE: sqlite
      VIKUNJA_DATABASE_PATH: /db/vikunja.db
      VIKUNJA_FILES_BASEPATH: /app/vikunja/files
      # No outbound mail. Upstream's reminder job only delivers over mail or a
      # webhook, so with this false a due date is something you see when you
      # open the app, not something that arrives. Block 10 says so out loud.
      VIKUNJA_MAILER_ENABLED: "false"
    volumes:
      - ./db:/db
      - ./files:/app/vikunja/files
    ports:
      # Loopback only: no other device on the wifi can reach 8097.
      - "127.0.0.1:8097:3456"

agent-readable mirror: /self-host/todoist.md

The files, if you'd rather do it yourself

The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.

compose.ymlthe services, pinned46 lines

authored from upstream docs, never pasted · 2,345 bytes

# Vikunja · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   installing ......... https://vikunja.io/docs/installing/
#   docker examples .... https://vikunja.io/docs/full-docker-example/
#   config reference ... https://vikunja.io/docs/config-options/
#   reverse proxy ...... https://vikunja.io/docs/reverse-proxy/
#   what to backup ..... https://vikunja.io/docs/what-to-backup/
#
# One service. Upstream's own quick start runs this image with SQLite and two
# mounts, and the image ships VIKUNJA_DATABASE_PATH=/db/vikunja.db already set,
# so a single container is the documented shape here rather than a shortcut.
# The API and the web interface are the same binary on the same port, 3456.
#
# The image is built FROM scratch and runs as uid 1000 with no group. There is
# no shell in it, which is why this file declares no healthcheck: nothing in the
# image could run one. It is also why /srv/vikunja/db and /srv/vikunja/files
# have to be owned by uid 1000 on the host, which is what step 2 of the prompt
# does. Everything else about this container is unwritable by design.
#
# Tag and digest were read from Docker Hub on 2026-08-05; the manifest list
# publishes linux/amd64 and linux/arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  vikunja:
    image: vikunja/vikunja:2.5.0@sha256:22df4c1bc8843c28d383bc5f52b59e7b601bf5f6560b36b29c0a500833c77fa3
    container_name: vikunja
    restart: unless-stopped
    env_file: /srv/vikunja/.env
    environment:
      # SQLite, written out even though it is the image default, because this
      # file is what a reviewer reads to find out where the data actually is.
      VIKUNJA_DATABASE_TYPE: sqlite
      VIKUNJA_DATABASE_PATH: /db/vikunja.db
      VIKUNJA_FILES_BASEPATH: /app/vikunja/files
      # No outbound mail. Upstream's reminder job only delivers over mail or a
      # webhook, so with this false a due date is something you see when you
      # open the app, not something that arrives. Block 10 says so out loud.
      VIKUNJA_MAILER_ENABLED: "false"
    volumes:
      - /srv/vikunja/db:/db
      - /srv/vikunja/files:/app/vikunja/files
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8097.
      - "127.0.0.1:8097:3456"
Caddyfilethe hostname and TLS33 lines

authored from upstream docs, never pasted · 1,322 bytes

# Vikunja · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://vikunja.io/docs/reverse-proxy/ 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
# also VIKUNJA_SERVICE_PUBLICURL in .env and the two have to agree: Vikunja
# refuses to start when the public URL is empty, and it builds the links it
# hands out from that value.

<DOMAIN> {
	# One origin serves the web interface and the JSON API, so there is no
	# second route and no CORS to arrange. Frames are denied because nothing
	# here is meant to be embedded, and the referrer is trimmed because task
	# and project names travel inside these URLs.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "DENY"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	encode zstd gzip

	# Caddy imposes no request body limit of its own, so attachments up to
	# Vikunja's own 20MB ceiling pass through with no size directive here.
	# 8097 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:8097
}
install.shthe same install, no agent176 lines

authored from upstream docs, never pasted · 7,763 bytes

#!/usr/bin/env bash
# Vikunja · 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://vikunja.io/docs/installing/
#   https://vikunja.io/docs/full-docker-example/
#   https://vikunja.io/docs/config-options/
#   https://vikunja.io/docs/reverse-proxy/
#   https://vikunja.io/docs/what-to-backup/
#
# One secret is generated here, on this machine: the key Vikunja signs session
# tokens with. It goes into /srv/vikunja/.env with mode 600 and is never
# printed. Read it back yourself with
#   sudo grep VIKUNJA_SERVICE_SECRET /srv/vikunja/.env
#
# DOMAIN_HOST also becomes VIKUNJA_SERVICE_PUBLICURL. Vikunja refuses to start
# when that value is empty, so the two are one decision, not two.
#
# There is one pause in this script. Only a human can create the first account,
# and registration stays open until they have.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/vikunja}"
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."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

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

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 ----------------------------------------------------
#
# The container runs as uid 1000 with no group, so db/ and files/ belong to that
# uid. Chowning them to yourself is the one change that stops it starting.

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

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64, so nothing downstream has to escape it. Without a
# value here Vikunja generates a fresh random one at every start, which signs
# out every logged-in session on every restart.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		VIKUNJA_SERVICE_PUBLICURL=https://${DOMAIN_HOST}
		VIKUNJA_SERVICE_TIMEZONE=UTC
		VIKUNJA_SERVICE_ENABLEREGISTRATION=true
		VIKUNJA_SERVICE_SECRET=$(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-vikunja"
	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 8097 is not one of them --------------------------

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

# --- 6. Start it -------------------------------------------------------------
#
# Vikunja creates its own SQLite schema on the first start. The image has no
# shell in it, so `docker compose logs` is the only window into a failure.

docker compose pull
docker compose up -d

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

curl -sS "https://${DOMAIN_HOST}/api/v1/info" | grep -q '"registration_enabled":true' \
	|| die "the info endpoint answered 200 but registration is not open, so no account can be made. Check step 3."

# --- 7. The first account, which only a human can create ---------------------

cat <<-SETUP

	Open https://${DOMAIN_HOST} now, follow "Create account", and register the
	one account you want. Until you do, anyone who finds this hostname can
	register instead. The next step closes registration and proves it is shut.

SETUP
printf 'Press Return once you are signed in. '
read -r _

sed -i 's/^VIKUNJA_SERVICE_ENABLEREGISTRATION=true$/VIKUNJA_SERVICE_ENABLEREGISTRATION=false/' "$APP_DIR/.env"
docker compose up -d --force-recreate
sleep 15

curl -sS "https://${DOMAIN_HOST}/api/v1/info" | grep -q '"registration_enabled":false' \
	|| die "registration is still open after the restart. Check $APP_DIR/.env and re-run."

# Upstream's register handler returns 404 once registration is off. This is the
# security assert in this script, not a formality.
reg="$(curl -sS -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{}' "https://${DOMAIN_HOST}/api/v1/register" || true)"
[ "$reg" = "404" ] || die "POST /api/v1/register returned ${reg}, not 404. Registration is still reachable. Stop and investigate."

# --- 8. The first backup, before day one ends --------------------------------
#
# Stopped, then copied. A SQLite file captured mid-write is not a backup, and
# nothing runs inside this container because there is no shell in it.

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

cat <<-DONE

	Vikunja is running at https://${DOMAIN_HOST}/

	  1. Registration is closed and POST /api/v1/register answers 404. To add
	     another person later, flip VIKUNJA_SERVICE_ENABLEREGISTRATION back to
	     true in $APP_DIR/.env, run docker compose up -d --force-recreate,
	     let them register, then set it to false and recreate again.
	  2. Nothing sends mail here. Upstream's reminder job only delivers over
	     mail or a webhook, so a due date is something you see when you open
	     the app, not something that arrives. If you came from Todoist, that
	     is the habit that does not survive the move.
	  3. Your signing key is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep VIKUNJA_SERVICE_SECRET $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  4. First backup written to $APP_DIR/backups: one archive holding
	     db/, files/, .env, compose.yml and Caddyfile. It is on the same disk
	     as the data, which is not a backup. Copy it somewhere else tonight.

DONE

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Todoist.

  • No reminder will find you. Upstream only schedules the reminder job when mail or webhooks can carry it, and this install has neither, so a due date is something you see when you open the app. That is the single habit a Todoist subscriber loses on day one, and it is worth deciding about before you migrate rather than after.
  • Three features sit behind a paid Vikunja Pro key: the admin panel, time tracking and audit logs. The code is AGPL and everything else works without a key, but if time tracking is why you are here, that is a subscription, not a saving.
  • You own the backups, and there is exactly one file that matters. Every task, project, comment and label is in db/vikunja.db, every attachment is a file next to it, and SQLite copied mid-write is not a backup, so the container has to stop for the five seconds it takes.
  • Importing your Todoist history means registering a developer app in your own Todoist account for a client id and secret, and upstream states your Vikunja has to be publicly reachable for the handshake. The import is real and it works; it is not one click.
  • SQLite does not take concurrent writes, which Vikunja reports to its own clients. One person, or a household, is the shape this install fits. A team hammering it wants PostgreSQL, and that is a different compose file.

Where this came from

“Default is a random secret which will be generated at each startup of Vikunja. (This means all already issued tokens will be invalid once you restart Vikunja)”

  • Upstream's own quick start runs a single container with SQLite, mounting one directory at /db and another at /app/vikunja/files, and states the container runs as user 1000 with no group. source
  • The API and the web interface are one binary in one image, so there is no second container and no separate frontend to route. source
  • service.publicurl is required whenever CORS is enabled, which it is by default, and service.secret regenerates on every start unless it is set. source
  • The register endpoint returns 404 once service.enableregistration is false, which is the assert this install uses to prove signups are shut. source
  • The reminder job is only scheduled when email reminders or webhooks are enabled, so an install with the mailer off delivers no reminders by mail. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Todoist?

    Not Todoist itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Vikunja. One container holding every task, project and due date, with no project cap and no per-seat bill. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 9 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.

  • What replaces Todoist?

    Vikunja. One container holding every task, project and due date, with no project cap and no per-seat bill. The only one here that is a to-do list first and a project board second, which is what a Todoist subscriber is actually replacing. It is one container with an embedded database, so the install is short, and the project ceiling and the filter limit that push people onto Pro are not there at all. Two things do not come across. Reminders will not chase you unless you give it a mail server or a webhook, and there is no voice capture. Its Todoist importer is real, and turning it on means registering a developer app in your own Todoist account first. Vikunja is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Todoist?

    512 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. Vikunja itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Todoist Pro, $7/mo — $84 a year.

  • How hard is it really?

    ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the Vikunja install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run Vikunja on my own computer instead of a server?

    Yes — that is the second path in the prompt box above. "On my computer" installs the same Vikunja on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: The list answers on http://localhost:8097, which means this computer and no other, so your phone cannot open it and no CalDAV client anywhere else can sync with it. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.

Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.