Most task apps are someone else's database with your to-do list inside it. The moment you depend on Todoist or Asana for the spine of your week, you've handed your planning, your deadlines, and your half-formed ideas to a subscription you don't control. Vikunja is the open-source way out: a fast, self-hostable task manager with lists, kanban boards, gantt charts, reminders, and CalDAV sync, all running on a VPS you own.
Vikunja is written in Go, ships as a single container, and is happy on a 1 GB VPS with Postgres alongside it. This guide takes you from a fresh Ubuntu server to a working https://tasks.example.com with your own admin account, public sign-ups disabled, your phone's calendar app syncing tasks over CalDAV, and a nightly backup that actually restores.
tasks.example.com at the servervikunja/vikunja image with Postgres behind Caddy in one docker-compose.ymlJWT secret - rotating it logs everyone outTotal time: about 20 minutes.
80 and 443 open to the internet for Let's EncryptVikunja itself is light - the API process idles around 40 MB of RAM. Postgres is the heavier half of the stack, which is why 1 GB is the comfortable floor rather than 512 MB. If you're already running other Docker workloads with a Postgres instance, you can point Vikunja at that database instead and skip the bundled one.
In your DNS provider, add an A record:
tasks.example.com → YOUR_VPS_IPV4
Add an AAAA record too if your server has IPv6. Verify it resolves before going further:
dig +short tasks.example.com
DNS has to point at your VPS before Caddy can fetch a TLS certificate.
On a fresh Ubuntu box:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/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/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm both are installed:
docker --version
docker compose version
If you use UFW:
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Caddy needs port 80 for the ACME HTTP challenge and 443 for HTTPS. Postgres and the Vikunja API stay on the internal Docker network and are never published to the host.
sudo mkdir -p /opt/vikunja
cd /opt/vikunja
sudo mkdir -p files caddy-data caddy-config
Everything persistent lives under /opt/vikunja: the Postgres data volume, uploaded attachments, and Caddy's certificates. Back up this one directory and you can rebuild the stack on any host.
Vikunja signs login tokens with a JWT secret. Generate a long random one now and a database password while you're at it:
openssl rand -hex 32 # JWT secret
openssl rand -hex 24 # Postgres password
Keep both somewhere safe. Create /opt/vikunja/.env with the values you just generated:
POSTGRES_PASSWORD=your_generated_db_password
VIKUNJA_JWT_SECRET=your_generated_jwt_secret
Vikunja merged its API and frontend into a single image, so the stack is three containers: the app, Postgres, and Caddy. Create /opt/vikunja/docker-compose.yml:
services:
vikunja:
image: vikunja/vikunja:0.24
container_name: vikunja
restart: unless-stopped
environment:
VIKUNJA_SERVICE_PUBLICURL: "https://tasks.example.com/"
VIKUNJA_SERVICE_JWTSECRET: "${VIKUNJA_JWT_SECRET}"
VIKUNJA_SERVICE_ENABLEREGISTRATION: "true"
VIKUNJA_DATABASE_TYPE: "postgres"
VIKUNJA_DATABASE_HOST: "db"
VIKUNJA_DATABASE_DATABASE: "vikunja"
VIKUNJA_DATABASE_USER: "vikunja"
VIKUNJA_DATABASE_PASSWORD: "${POSTGRES_PASSWORD}"
VIKUNJA_FILES_BASEPATH: "/app/vikunja/files"
volumes:
- ./files:/app/vikunja/files
networks:
- vikunja-net
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
container_name: vikunja-db
restart: unless-stopped
environment:
POSTGRES_DB: "vikunja"
POSTGRES_USER: "vikunja"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
volumes:
- ./db-data:/var/lib/postgresql/data
networks:
- vikunja-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vikunja"]
interval: 10s
timeout: 5s
retries: 5
caddy:
image: caddy:2
container_name: vikunja-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy-data:/data
- ./caddy-config:/config
networks:
- vikunja-net
networks:
vikunja-net:
A few notes:
vikunja/vikunja:0.24 is the merged single-image line. Pinning to a minor version means an upgrade is a deliberate edit, not a surprise the next time you pull.VIKUNJA_SERVICE_PUBLICURL must be the full public URL with the protocol and a trailing slash. Get this wrong and the frontend will load but every API call fails with a CORS or "could not reach API" error.3456 internally. We never publish that port - Caddy reaches it across the Docker network.depends_on with condition: service_healthy stops Vikunja from racing Postgres on boot and crash-looping while the database is still initializing.Create /opt/vikunja/Caddyfile:
tasks.example.com {
encode zstd gzip
reverse_proxy vikunja:3456
}
Caddy requests a Let's Encrypt certificate for tasks.example.com automatically on first boot. There's nothing else to configure.
cd /opt/vikunja
sudo docker compose up -d
sudo docker compose logs -f
Watch the logs until Postgres reports it's ready, Vikunja runs its migrations, and Caddy confirms the certificate was issued. Then open https://tasks.example.com in a browser. You should see the Vikunja login screen with a Register link.
Click Register, then create the account you want to keep as your own. Unlike some apps, Vikunja does not anoint the first user as a global admin - there is no super-admin role in the web UI by default, so the first account is simply your account.
The important part is what you do next.
Once your account exists, edit /opt/vikunja/docker-compose.yml and flip the flag:
VIKUNJA_SERVICE_ENABLEREGISTRATION: "false"
Then apply it:
cd /opt/vikunja
sudo docker compose up -d
The Register link disappears. To add teammates later, either flip registration back on briefly while they sign up, or create users from the command line:
sudo docker exec -it vikunja /app/vikunja/vikunja user create \
-u teammate -e [email protected]
This is where Vikunja earns its keep. A "project" (the old term was "list") can be displayed several ways, and you switch between them with the tabs at the top of any project:
Backlog → Doing → Review → Done).The same tasks back all four views. Mark something done on the Kanban board and it's checked off in the List view too. Other day-one features worth knowing:
Submit invoice *important !2 tomorrow @finance in the add box and Vikunja parses the priority, due date, label, and assignee out of the text.Vikunja speaks CalDAV, so your tasks can appear in the same app that holds your calendar - Apple Reminders, Thunderbird, DAVx5 on Android, and others. This is the feature that makes a self-hosted task manager feel native instead of like one more tab.
The CalDAV endpoint is:
https://tasks.example.com/dav/principals/YOUR_USERNAME/
Before you can connect, generate an app password rather than using your login password. In the web UI go to Settings → Caldav (or Settings → API Tokens / App Passwords depending on version), and create one scoped to CalDAV. Then in your client:
tasks.example.com, username is your Vikunja username, password is the app password.Each Vikunja project shows up as a separate task list (VTODO collection) on the client side. Changes sync both ways.
Two things hold your data: the Postgres database (every task, project, and label) and the files/ directory (uploaded attachments). A backup needs both.
Create /usr/local/bin/vikunja-backup.sh:
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/var/backups/vikunja"
DATE="$(date +%F-%H%M)"
mkdir -p "$BACKUP_DIR"
docker exec vikunja-db pg_dump -U vikunja vikunja \
| gzip > "$BACKUP_DIR/vikunja-db-$DATE.sql.gz"
tar -czf "$BACKUP_DIR/vikunja-files-$DATE.tar.gz" -C /opt/vikunja files
find "$BACKUP_DIR" -name "vikunja-*.gz" -mtime +14 -delete
Make it executable and schedule a nightly run:
sudo chmod +x /usr/local/bin/vikunja-backup.sh
echo "25 3 * * * root /usr/local/bin/vikunja-backup.sh" | \
sudo tee /etc/cron.d/vikunja-backup
For off-site safety, push /var/backups/vikunja to S3 or Backblaze B2 on the same schedule. Our restic to S3 guide covers an encrypted version of exactly this.
To restore the database:
gunzip -c /var/backups/vikunja/vikunja-db-DATE.sql.gz \
| docker exec -i vikunja-db psql -U vikunja vikunja
Vikunja runs database migrations on startup, so the safe upgrade path is: back up, bump the tag, pull, restart.
cd /opt/vikunja
sudo /usr/local/bin/vikunja-backup.sh # snapshot first
# edit docker-compose.yml: vikunja/vikunja:0.24 -> the new minor version
sudo docker compose pull
sudo docker compose up -d
sudo docker compose logs -f vikunja # watch migrations finish
Read the release notes before crossing a minor version. Rolling back after a migration is far easier with a fresh dump than with a half-migrated database, which is why the backup comes first.
A personal task manager is a good candidate for VPN-only access. Pair this with our Tailscale guide so tasks.example.com only resolves and answers inside your tailnet. The web app and CalDAV both work fine over WireGuard or Tailscale, and you can drop ports 80/443 from the public firewall entirely.
The page loads but shows "Could not reach Vikunja API." Almost always a wrong VIKUNJA_SERVICE_PUBLICURL. It must match the exact URL in the browser, include https://, and end with a trailing slash. Fix it, then docker compose up -d.
Caddy returns 502 Bad Gateway. Vikunja hasn't finished starting or crashed on boot, usually because Postgres wasn't ready. Check sudo docker compose logs vikunja. The depends_on healthcheck should prevent this, but a slow first-boot Postgres init can still trip it - just wait and retry the page.
Everyone got logged out after a config change. You changed the JWT secret, or the .env file wasn't loaded and Vikunja generated a new one. Set VIKUNJA_SERVICE_JWTSECRET explicitly from your .env and keep it stable across restarts.
CalDAV client refuses to connect. Use an app password, not your login password, and confirm the URL ends in your username with a trailing slash. Test the base domain in a browser first - the client never gets gentler TLS than your laptop does.
Migrations fail on upgrade. Restore the pre-upgrade dump, pin back to the previous tag, and start again. Then read the release notes for the version you skipped before retrying - some major jumps require an intermediate version.
vikunja binary inside the container. Generate an API token under Settings → API Tokens and script task creation from shell aliases, iOS Shortcuts, or cron jobs.VIKUNJA_MAILER_* environment variables to point at an SMTP server and Vikunja will send due-date reminders and account emails. Pair it with a transactional provider or your own Listmonk-adjacent mail setup.Self-hosted Vikunja is the kind of tool that disappears into your routine. Once your phone's calendar app is showing your tasks and a nightly dump is landing in S3, you stop thinking about whether the service will still exist next year - because you're the one running it.
Need a VPS for a stack like this with room for Docker and Postgres? Our Linux plans include fast NVMe storage, IPv6, and plenty of headroom. See the options.