Task Management
    Docker

    Deploy Vikunja on a VPS

    Deploy Vikunja — an open source, self-hosted task manager with list, table, Kanban, and Gantt views — on a RamNode KVM VPS with PostgreSQL and Nginx TLS.

    Vikunja is an open source, self-hosted task manager. API and frontend ship as a single Go binary, which means one container, no Node runtime, and no build step. It gives you list, table, Kanban, and Gantt views, saved filters, labels, teams, sharing, CalDAV, and a REST API.

    It is the lightest of the serious self-hosted task tools. A 2 GB RamNode VPS runs it comfortably for a small team, which puts it in a completely different cost bracket than Plane.

    This guide covers Vikunja 2.3.x on Ubuntu 24.04 LTS with PostgreSQL, fronted by Nginx with Let's Encrypt.


    1. Version note before you start

    Vikunja hit 1.0.0 in January 2026 after eight years, then moved fast: 2.0.0 in February with a breaking session-auth rebuild and Typesense support removed, and 2.3.0 in April.

    The 2.x line has shipped a lot of security fixes in a short window, including CVEs in the API and the link-share hash disclosure fixed across 2.2.1 and 2.2.2. Read that as a project that is receiving and acting on security reports, not as a project that is unsafe. But it does mean you need a real upgrade habit. Do not deploy Vikunja and forget it for a year.

    If you are migrating from a 0.x install, read the 1.0.0 and 2.0.0 release notes before you touch anything. The session auth change in 2.0.0 is breaking.


    2. Pick the right RamNode plan

    UsersRamNode sizingDatabase
    1, personal1 GB RAM, 1 vCPU, 20 GB SSDSQLite is fine
    2 to 152 GB RAM, 2 vCPU, 40 GB SSDPostgreSQL
    15 to 1004 GB RAM, 2 vCPU, 60 GB+ SSDPostgreSQL

    SQLite genuinely works for single-user Vikunja. The moment you add a second user, move to Postgres. This guide uses Postgres because that is where anything shared ends up anyway, and migrating later is more annoying than starting there.

    Disk usage is driven by task attachments and project backgrounds, not by the task rows themselves. A team's task database stays small for years.

    Order a KVM plan. Pick the region nearest your users.


    3. Prepare the VPS

    shell
    apt update && apt upgrade -y
    apt install -y curl ca-certificates ufw fail2ban
    timedatectl set-timezone UTC
    adduser deploy
    usermod -aG sudo deploy

    Firewall:

    shell
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable

    On a 1 GB plan add swap:

    shell
    fallocate -l 2G /swapfile
    chmod 600 /swapfile
    mkswap /swapfile
    swapon /swapfile
    echo '/swapfile none swap sw 0 0' >> /etc/fstab

    DNS

    Point an A record for tasks.example.com at the VPS IP. Verify:

    shell
    dig +short tasks.example.com

    4. Install Docker

    shell
    curl -fsSL https://get.docker.com | sh -
    usermod -aG docker deploy
    systemctl enable --now docker
    docker compose version

    Vikunja also ships .deb packages, an RPM, a NixOS module, and plain binaries. If you would rather not run Docker on a 1 GB box, the deb is a legitimate option. The rest of this guide assumes Docker.


    5. Deploy

    Log in as deploy.

    shell
    mkdir -p ~/vikunja && cd ~/vikunja
    mkdir -p files db

    Create .env:

    shell
    cat > .env <<'EOF'
    POSTGRES_PASSWORD=CHANGE_ME
    VIKUNJA_JWT_SECRET=CHANGE_ME_TOO
    PUBLIC_URL=https://tasks.example.com
    EOF
    chmod 600 .env

    Generate real values:

    shell
    openssl rand -hex 32   # once for each

    Create docker-compose.yml:

    shell
    services:
      db:
        image: postgres:17-alpine
        restart: unless-stopped
        environment:
          POSTGRES_DB: vikunja
          POSTGRES_USER: vikunja
          POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
        volumes:
          - ./db:/var/lib/postgresql/data
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U vikunja -d vikunja"]
          interval: 10s
          timeout: 5s
          retries: 10
    
      vikunja:
        image: vikunja/vikunja:2.3
        restart: unless-stopped
        ports:
          - "127.0.0.1:3456:3456"
        environment:
          VIKUNJA_SERVICE_PUBLICURL: ${PUBLIC_URL}
          VIKUNJA_SERVICE_JWTSECRET: ${VIKUNJA_JWT_SECRET}
          VIKUNJA_SERVICE_ENABLEREGISTRATION: "false"
          VIKUNJA_DATABASE_TYPE: postgres
          VIKUNJA_DATABASE_HOST: db
          VIKUNJA_DATABASE_DATABASE: vikunja
          VIKUNJA_DATABASE_USER: vikunja
          VIKUNJA_DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
          VIKUNJA_FILES_MAXSIZE: 50MB
          VIKUNJA_LOG_LEVEL: INFO
        volumes:
          - ./files:/app/vikunja/files
        depends_on:
          db:
            condition: service_healthy

    Note the binds:

    • 127.0.0.1:3456:3456 keeps Vikunja off the public interface. Docker writes its own iptables rules and would otherwise bypass UFW entirely, publishing an unauthenticated HTTP port to the internet.
    • The image runs as UID 1000 with no group. Fix ownership before first start or the container exits immediately:
    shell
    sudo chown -R 1000 files

    Pin the image tag. vikunja/vikunja:latest tracks stable, unstable tracks main. Pinning to a minor line like 2.3 gives you patches without surprise majors.

    Bring it up:

    shell
    docker compose up -d
    docker compose logs -f vikunja

    Wait for the migrations to finish. You are looking for a version line and:

    shell
    ⇨ http server started on [::]:3456

    There is no shell in the Vikunja container. docker exec -it vikunja sh will not work. Use docker compose logs for everything.

    PUID and PGID do nothing here either. Those are LinuxServer.io conventions. Use Compose's user: directive if you need a different UID.


    6. Nginx and Let's Encrypt

    shell
    sudo apt install -y nginx certbot python3-certbot-nginx

    /etc/nginx/sites-available/vikunja:

    shell
    server {
        listen 80;
        server_name tasks.example.com;
        return 301 https://$host$request_uri;
    }
    
    server {
        listen 443 ssl http2;
        server_name tasks.example.com;
    
        client_max_body_size 50M;
    
        location / {
            proxy_pass http://127.0.0.1:3456;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_read_timeout 300s;
        }
    }

    Keep client_max_body_size in sync with VIKUNJA_FILES_MAXSIZE. If Nginx is smaller, uploads fail with a 413 and the UI reports nothing useful.

    shell
    sudo ln -s /etc/nginx/sites-available/vikunja /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t && sudo systemctl reload nginx
    sudo certbot --nginx -d tasks.example.com

    7. Create the first user

    There is no default account. Registration is disabled in the config above, so create your user from the CLI:

    shell
    docker compose exec vikunja /app/vikunja/vikunja user create -u admin -e admin@example.com

    It prompts for a password. Then log in at https://tasks.example.com.

    To add more users later, use the same command, or temporarily flip VIKUNJA_SERVICE_ENABLEREGISTRATION to true, register, and flip it back.

    Other useful CLI subcommands run the same way:

    shell
    docker compose exec vikunja /app/vikunja/vikunja user list
    docker compose exec vikunja /app/vikunja/vikunja user change-status --disable <id>
    docker compose exec vikunja /app/vikunja/vikunja dump

    8. CORS and the "unauthorized" error

    If Vikunja returns unauthorized on every action, VIKUNJA_SERVICE_PUBLICURL does not match what the browser is actually hitting. CORS is on by default and validates against that URL.

    The rule: PUBLIC_URL must exactly match the address in the browser bar, scheme included. If you are testing on a non-standard port before Nginx is in place, the port has to be in the URL too (http://1.2.3.4:3456). Alternatively set VIKUNJA_CORS_ENABLE=false, but only when Vikunja is behind a proxy on the same origin.


    9. SMTP on RamNode

    RamNode does not allow mail services on their VPS plans and blocks outbound port 25. Relay through an external transactional provider on port 587 instead.

    shell
          VIKUNJA_MAILER_ENABLED: "true"
          VIKUNJA_MAILER_HOST: smtp.provider.example
          VIKUNJA_MAILER_PORT: "587"
          VIKUNJA_MAILER_AUTHTYPE: login
          VIKUNJA_MAILER_USERNAME: ${SMTP_USER}
          VIKUNJA_MAILER_PASSWORD: ${SMTP_PASS}
          VIKUNJA_MAILER_FROMEMAIL: vikunja@example.com
          VIKUNJA_MAILER_SKIPTLSVERIFY: "false"

    Put the credentials in .env, not in the compose file. Add SPF and DKIM records for your sending domain at your DNS host.

    Without a mailer, password resets and task reminder emails do not work. Everything else does.


    10. Backups

    shell
    #!/usr/bin/env bash
    set -euo pipefail
    STAMP=$(date +%Y%m%d-%H%M)
    DEST=/var/backups/vikunja
    APP=/home/deploy/vikunja
    mkdir -p "$DEST"
    
    docker compose -f "$APP/docker-compose.yml" exec -T db \
      pg_dump -U vikunja -d vikunja | gzip > "$DEST/vikunja-db-$STAMP.sql.gz"
    
    tar czf "$DEST/vikunja-files-$STAMP.tar.gz" -C "$APP" files
    cp "$APP/.env" "$DEST/env-$STAMP"
    
    find "$DEST" -type f -mtime +21 -delete
    shell
    sudo install -m 700 vikunja-backup.sh /usr/local/bin/
    sudo crontab -e
    # 0 3 * * * /usr/local/bin/vikunja-backup.sh >> /var/log/vikunja-backup.log 2>&1

    Vikunja also has a built-in dump command that produces a single portable archive of the database, files, config, and version. It is the right tool for migrating between hosts, less so for nightly backups since it writes into the container filesystem.

    Ship a copy off the box. Restore-test once a year.


    11. Upgrades

    shell
    cd ~/vikunja
    /usr/local/bin/vikunja-backup.sh

    Edit the pinned tag in docker-compose.yml, then:

    shell
    docker compose pull
    docker compose up -d
    docker compose logs -f vikunja

    Migrations run automatically at startup. Watch for them to complete.

    Never skip a major. Read the release notes for every major before you jump: 2.0.0 rebuilt session authentication and removed Typesense, and there is more coming on the 2.x line.

    Given the volume of security fixes in 2.x, subscribe to the changelog RSS feed and patch promptly. This is the maintenance cost of running Vikunja.


    12. Troubleshooting

    Container exits at startup with a usermod message. Ownership on files/ or the db directory. chown -R 1000 files.

    unauthorized on every request. VIKUNJA_SERVICE_PUBLICURL mismatch. See section 8.

    Migration failed, connection refused on first boot. Postgres was not ready. The depends_on healthcheck above prevents it. If you dropped that, restart: unless-stopped will eventually sort it out.

    Data exports produce nothing. /tmp must be writable inside the container. If you set a custom user:, mount a writable tmpfs at /tmp.

    docker exec ... sh fails. Expected. There is no shell in the image.

    Gantt or Kanban view empty after upgrade. Views became per-project and configurable in 0.24.0. Check the project's view settings.


    Why Vikunja

    Vikunja is the correct answer more often than people expect. One binary, one container, a Postgres database, and about 200 MB of RAM at rest. It runs on RamNode's cheapest KVM plans, it has a real API and CalDAV, and it does not require you to think about RabbitMQ or MinIO.

    If your team needs sprints, epics, and intake triage, deploy Plane instead and pay for the RAM. If your team needs to track work, Vikunja does that on a $5 VPS.