Self-Host Vikunja on a VPS for Task Management
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.
TL;DR
- Install Docker and Docker Compose on a small VPS
- Point a subdomain like
tasks.example.comat the server - Run the
vikunja/vikunjaimage with Postgres behind Caddy in onedocker-compose.yml - Generate a long
JWTsecret - rotating it logs everyone out - Register the first account, then turn off open registration
- Sync tasks to your phone or laptop calendar over CalDAV
- Back up the Postgres database and the attachments volume nightly
Total time: about 20 minutes.
What You Need
- A VPS with at least 1 GB RAM running Ubuntu 22.04 or 24.04
- A domain or subdomain you can point at the server
- Ports
80and443open to the internet for Let's Encrypt - Root or sudo access
Vikunja 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.
Step 1: Point a Subdomain at Your VPS
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.
Step 2: Install Docker and Docker Compose
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
Step 3: Open the Firewall
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.
Step 4: Create the Project Directory
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.
Step 5: Generate the Secrets
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
Step 6: Write the Compose File
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:
- Pin the image tag.
vikunja/vikunja:0.24is 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_PUBLICURLmust 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.- The Vikunja container listens on
3456internally. We never publish that port - Caddy reaches it across the Docker network. depends_onwithcondition: service_healthystops Vikunja from racing Postgres on boot and crash-looping while the database is still initializing.
Step 7: Write the Caddyfile
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.
Step 8: Start the Stack
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.
Step 9: Create Your Account and Lock Down Registration
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]
Step 10: Learn the Views
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:
- List - the classic flat checklist with sub-tasks, due dates, and priorities.
- Kanban - drag cards between columns. Columns map to a custom status field, so a board can model a real workflow (
Backlog → Doing → Review → Done). - Gantt - a timeline view that draws each task as a bar from start to due date. Useful for anything with sequencing.
- Table - a spreadsheet-style grid where you choose which columns to show and sort by any of them.
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:
- Labels are free-form tags you create on the fly; filter any view by them.
- Saved filters behave like smart lists - "everything due this week across all projects," for example - and live in the sidebar next to your projects.
- Quick add magic lets you type
Submit invoice *important !2 tomorrow @financein the add box and Vikunja parses the priority, due date, label, and assignee out of the text.
Step 11: Sync Tasks to Your Calendar with CalDAV
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:
- Apple (iOS/macOS): Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account. Server is
tasks.example.com, username is your Vikunja username, password is the app password. - Thunderbird: New Calendar → On the Network → enter the CalDAV URL above.
- Android: install DAVx5, add a login with the base URL and your app password, and let it discover the task collections.
Each Vikunja project shows up as a separate task list (VTODO collection) on the client side. Changes sync both ways.
Step 12: Back Up the Database and Files
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
Step 13: Upgrade Vikunja Safely
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.
Optional: Put Vikunja Behind a VPN
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.
Troubleshooting
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.
Going Further
- Use the API and CLI. Vikunja ships a documented REST API and a
vikunjabinary inside the container. Generate an API token under Settings → API Tokens and script task creation from shell aliases, iOS Shortcuts, or cron jobs. - Add email reminders. Set the
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. - Migrate from Todoist. Vikunja has built-in importers for Todoist, Trello, and Microsoft To Do under Settings → Migrate. Authorize the source and it pulls your projects across in one pass.
- Harden the box. A public task manager deserves the same baseline as any other internet-facing service. Our SSH hardening and fail2ban guide is the natural companion to this stack.
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.