All articles
TutorialsJun 29, 2026 · 23 min read · By The RDP.sh Team

Self-Host Vikunja on a VPS for Task Management

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.com at the server
  • Run the vikunja/vikunja image with Postgres behind Caddy in one docker-compose.yml
  • Generate a long JWT secret - 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 80 and 443 open 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
The JWT secret is not just a config value you can regenerate later without consequence. Every active login session is signed with it. If you change or lose it, every user - including you - is logged out and has to sign in again. Treat it like a password and store it in your password manager before moving on.

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.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.
  • The Vikunja container listens on 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.

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.

With `VIKUNJA_SERVICE_ENABLEREGISTRATION` set to `true`, anyone who finds your URL can create an account on your instance. Register your own account first, then turn registration off and restart. Until you do, you're running an open writable service on the public internet.

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 @finance in 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.

Tarring the live Postgres data directory produces a backup that may not restore cleanly. Always dump the database with `pg_dump` while it's running instead. The script below does that, then archives the attachments separately.

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 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.
  • 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.