Agile PM
    Docker Compose

    Deploy Taiga on a VPS

    Deploy Taiga, the agile project management platform for Scrum and Kanban teams, on a RamNode KVM VPS with Docker Compose, Nginx, and Let's Encrypt.

    Taiga is an agile project management platform built for Scrum and Kanban teams. It ships user stories, sprints, epics, issue tracking, a wiki, and importers for Jira, Trello, and GitHub. It is a Django backend with an Angular frontend, a Node events service, RabbitMQ, Celery workers, and PostgreSQL, orchestrated with Docker Compose.

    This guide deploys Taiga on a RamNode KVM VPS behind an Nginx reverse proxy with a Let's Encrypt certificate. It also covers the gotchas the upstream docs skip: the compose v1 scripts, the websocket path, and what happens when you change the domain after install.


    1. Understand the Stack Before You Size It

    Taiga's compose file starts nine containers:

    ContainerRole
    taiga-dbPostgreSQL
    taiga-backDjango API
    taiga-asyncCelery worker
    taiga-async-rabbitmqBroker for Celery
    taiga-frontAngular frontend served by Nginx
    taiga-eventsNode websocket service
    taiga-events-rabbitmqBroker for events
    taiga-protectedSigned URL service for attachments
    taiga-gatewayInternal Nginx that routes all of the above

    Two separate RabbitMQ instances is not a mistake. Taiga deliberately separates the Celery broker from the events broker. Each one is an Erlang VM with its own baseline memory cost, and together they are the single largest reason Taiga needs more RAM than its feature set suggests.

    RamNode plan sizing

    UsersvCPURAMDisk
    Evaluation / small team, up to 1024 GB40 GB
    10 to 40 active2 to 46 to 8 GB60 GB
    40+ with heavy attachments48 GB+100 GB+

    Upstream says 2 GB works. It boots on 2 GB. It does not survive a real workday plus a pip migration on 2 GB. Start at 4 GB and add swap.

    Order a KVM plan. Docker needs a real kernel.


    2. Prerequisites

    • RamNode KVM VPS running Ubuntu 24.04 LTS
    • A non-root sudo user
    • A DNS A record for taiga.example.com pointing at the VPS
    • Ports 80 and 443 open

    Decide the hostname now and do not change it. Taiga bakes TAIGA_DOMAIN into the frontend build config and into stored attachment URLs. Changing it later means editing .env, recreating containers, and fixing the Django sites table by hand. Pick the final hostname before you create a single project.

    shell
    sudo hostnamectl set-hostname taiga
    sudo timedatectl set-timezone UTC
    sudo apt update && sudo apt -y upgrade
    sudo apt -y install git curl ca-certificates ufw nginx

    Swap

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

    Firewall

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

    3. Install Docker Engine

    shell
    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 -y install docker-ce docker-ce-cli containerd.io \
      docker-buildx-plugin docker-compose-plugin
    
    sudo usermod -aG docker "$USER"
    newgrp docker
    docker compose version

    4. Clone taiga-docker

    Use the stable branch. The main branch is for development.

    shell
    sudo mkdir -p /opt/taiga
    sudo chown "$USER":"$USER" /opt/taiga
    cd /opt
    
    git clone https://github.com/kaleidos-ventures/taiga-docker.git taiga
    cd /opt/taiga
    git checkout stable

    The repository has moved between the kaleidos-ventures and taigaio GitHub organizations. Both URLs currently resolve. If the clone above fails, try https://github.com/taigaio/taiga-docker.git.

    Fix the compose v1 scripts

    This is the first thing that breaks on a modern Docker install and it is not in the upstream README. The helper scripts still call docker-compose with a hyphen, which is Compose v1. Docker's current packages ship Compose v2 as docker compose, a plugin, with no hyphenated binary.

    Check:

    shell
    grep -rn 'docker-compose ' launch-taiga.sh launch-all.sh taiga-manage.sh

    Patch them:

    shell
    sed -i 's/docker-compose /docker compose /g' \
      launch-taiga.sh launch-all.sh taiga-manage.sh
    chmod +x launch-taiga.sh taiga-manage.sh

    Alternatively install the compatibility shim and leave the scripts alone:

    shell
    sudo apt -y install docker-compose-v2
    sudo ln -s /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose

    Patching the scripts is cleaner. The shim will bite you later.

    Skip launch-all.sh

    launch-all.sh starts Taiga plus Penpot, the design tool from the same team. On a 4 GB VPS that is a bad trade. Use launch-taiga.sh.


    5. Configure the Environment

    The .env file already exists in the repo. Edit it in place.

    shell
    cd /opt/taiga
    openssl rand -hex 48       # SECRET_KEY
    openssl rand -base64 24 | tr -d '/+='   # POSTGRES_PASSWORD
    openssl rand -base64 24 | tr -d '/+='   # RABBITMQ_PASS
    openssl rand -hex 32       # RABBITMQ_ERLANG_COOKIE
    
    nano .env

    Set:

    shell
    # Where Taiga is served. Nginx terminates TLS, so the public scheme is https.
    TAIGA_SCHEME=https
    TAIGA_DOMAIN=taiga.example.com
    SUBPATH=""
    WEBSOCKETS_SCHEME=wss
    
    # Django secret. Rotating this invalidates every session.
    SECRET_KEY="<output of openssl rand -hex 48>"
    
    # Database
    POSTGRES_DB=taiga
    POSTGRES_USER=taiga
    POSTGRES_PASSWORD=<strong-password>
    
    # SMTP. See section 9. Leave as console until you have a relay.
    EMAIL_BACKEND=console
    EMAIL_HOST=smtp.example.com
    EMAIL_PORT=587
    EMAIL_HOST_USER=
    EMAIL_HOST_PASSWORD=
    EMAIL_USE_TLS=True
    EMAIL_USE_SSL=False
    DEFAULT_FROM_EMAIL=taiga@example.com
    
    # RabbitMQ. Both brokers read these.
    RABBITMQ_USER=taiga
    RABBITMQ_PASS=<strong-password>
    RABBITMQ_VHOST=taiga
    RABBITMQ_ERLANG_COOKIE=<output of openssl rand -hex 32>
    
    # Signed attachment URL lifetime in seconds.
    ATTACHMENTS_MAX_AGE=360
    
    # Turn off the upstream telemetry ping.
    ENABLE_TELEMETRY=False
    
    # Public registration. See section 8. Start with it off.
    PUBLIC_REGISTER_ENABLED=False
    shell
    chmod 600 .env

    Notes on these values:

    WEBSOCKETS_SCHEME must be wss when TAIGA_SCHEME is https. A mismatch produces a frontend that loads fine and silently never updates in real time. Boards will not move for other users until they refresh.

    RABBITMQ_ERLANG_COOKIE must be identical across both RabbitMQ containers. It comes from .env, so it will be, but if you split the stack across hosts later, remember it.

    SUBPATH stays empty unless you are serving Taiga at example.com/taiga. Subpath deployments need matching Nginx config and are not covered here.

    Bind the gateway to loopback

    By default the gateway publishes port 9000 on all interfaces. Do not leave an unauthenticated Django admin exposed while you set up TLS.

    shell
    nano /opt/taiga/docker-compose.yml

    Find the taiga-gateway service and change:

    shell
        ports:
          - "9000:80"

    to:

    shell
        ports:
          - "127.0.0.1:9000:80"

    6. First Boot

    shell
    cd /opt/taiga
    ./launch-taiga.sh

    The first run pulls every image and runs Django migrations. Expect five to ten minutes on a modest plan.

    Watch it:

    shell
    docker compose logs -f taiga-back

    Check all nine containers:

    shell
    docker compose ps --format "table {{.Name}}\t{{.Status}}"

    taiga-db should show (healthy). Everything else should show Up.

    Create the superuser

    Taiga does not seed an admin account. Create one:

    shell
    cd /opt/taiga
    ./taiga-manage.sh createsuperuser

    This runs manage.py createsuperuser in a throwaway container against the same database. Answer the prompts. Use a real email address, because that is the account you will use for password reset if you lock yourself out.

    Verify the gateway responds:

    shell
    curl -I http://127.0.0.1:9000/

    7. Nginx Reverse Proxy and TLS

    Issue the certificate:

    shell
    sudo apt -y install certbot python3-certbot-nginx
    sudo systemctl stop nginx
    sudo certbot certonly --standalone -d taiga.example.com \
      --agree-tos -m admin@example.com --no-eff-email

    Add the websocket upgrade map:

    shell
    sudo nano /etc/nginx/conf.d/upgrade-map.conf
    shell
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    Write the server block:

    shell
    sudo nano /etc/nginx/sites-available/taiga
    shell
    server {
        listen 80;
        listen [::]:80;
        server_name taiga.example.com;
    
        location /.well-known/acme-challenge/ {
            root /var/www/html;
        }
    
        location / {
            return 301 https://$host$request_uri;
        }
    }
    
    server {
        listen 443 ssl;
        listen [::]:443 ssl;
        http2 on;
        server_name taiga.example.com;
    
        ssl_certificate     /etc/letsencrypt/live/taiga.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/taiga.example.com/privkey.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_prefer_server_ciphers off;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 1d;
    
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    
        # Attachment uploads.
        client_max_body_size 128m;
    
        location / {
            proxy_pass http://127.0.0.1:9000;
            proxy_http_version 1.1;
    
            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 https;
    
            proxy_set_header Upgrade    $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
    
            proxy_redirect off;
            proxy_read_timeout 300s;
        }
    }

    The single location / block is correct here. Taiga's internal gateway already routes /api, /admin, /events, /media, and the static frontend to the right containers. Do not try to recreate that routing in the host Nginx. You will get it subtly wrong.

    The Upgrade and Connection headers on location / cover the /events websocket. Without them, real-time board updates fail silently.

    Enable it:

    shell
    sudo ln -s /etc/nginx/sites-available/taiga /etc/nginx/sites-enabled/
    sudo rm -f /etc/nginx/sites-enabled/default
    sudo nginx -t
    sudo systemctl start nginx

    Add the renewal deploy hook:

    shell
    sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
    echo -e '#!/bin/sh\nsystemctl reload nginx' \
      | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
    sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
    sudo certbot renew --dry-run

    Browse to https://taiga.example.com and log in with the superuser you created.


    8. Registration, Admin, and Hardening

    Public registration

    PUBLIC_REGISTER_ENABLED must be set in both the backend and the frontend, and Taiga is case-sensitive about it in a way that is easy to get wrong. The backend expects Python-style True or False. The frontend expects JavaScript-style true or false. The .env file feeds both services from a single variable, which works, but if you ever set these directly in docker-compose.yml under the service environment blocks, match the casing per service.

    Leave registration off for a private instance. Invite users from within a project instead.

    If you do enable it:

    shell
    PUBLIC_REGISTER_ENABLED=True
    shell
    cd /opt/taiga
    docker compose down && ./launch-taiga.sh

    Note that GitHub and GitLab OAuth buttons only appear when public registration is on. That is a deliberate upstream coupling, not a bug.

    The Django admin

    Taiga exposes Django admin at /admin/. The API is stateless and token-based, but the admin uses session cookies. Behind HTTPS this works out of the box. Over plain HTTP it does not, which is one more reason not to run this without TLS.

    Restrict access to /admin/ by IP if your team has stable addresses. Add to the server block above, before location /:

    shell
        location /admin/ {
            allow 203.0.113.0/24;
            deny all;
    
            proxy_pass http://127.0.0.1:9000;
            proxy_http_version 1.1;
            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 https;
        }

    Attachment protection

    taiga-protected issues short-lived signed URLs for attachments. ATTACHMENTS_MAX_AGE=360 means a signed link is valid for six minutes. Lower it if you are paranoid. Do not raise it to something like 86400, which is functionally the same as making every attachment public to anyone who has ever seen a link.

    Telemetry

    Set ENABLE_TELEMETRY=False if you do not want the instance reporting usage upstream.


    9. Outbound Email

    RamNode does not permit mail services on their VPS plans. Use an external SMTP relay for Taiga's invitations, mentions, and password resets.

    Set EMAIL_BACKEND=smtp and fill in the relay details in .env:

    shell
    EMAIL_BACKEND=smtp
    EMAIL_HOST=smtp.postmarkapp.com
    EMAIL_PORT=587
    EMAIL_HOST_USER=<relay-username>
    EMAIL_HOST_PASSWORD=<relay-password>
    EMAIL_USE_TLS=True
    EMAIL_USE_SSL=False
    DEFAULT_FROM_EMAIL=taiga@example.com

    Recreate:

    shell
    cd /opt/taiga
    docker compose down && ./launch-taiga.sh

    Test from a Django shell:

    shell
    ./taiga-manage.sh shell
    shell
    from django.core.mail import send_mail
    send_mail('Taiga test', 'It works.', 'taiga@example.com', ['you@example.com'])

    While EMAIL_BACKEND=console, mail is written to the taiga-back log instead of sent. That is useful for testing invitations without a relay:

    shell
    docker compose logs taiga-back | grep -A 20 'Subject:'

    10. Backups

    Three things matter: PostgreSQL, the media volume, and .env.

    shell
    sudo nano /usr/local/bin/taiga-backup.sh
    shell
    #!/usr/bin/env bash
    set -euo pipefail
    
    STACK=/opt/taiga
    DEST=/var/backups/taiga
    STAMP=$(date +%F-%H%M)
    KEEP_DAYS=14
    
    mkdir -p "$DEST"
    cd "$STACK"
    
    # Database
    docker compose exec -T taiga-db pg_dump -U taiga -Fc taiga \
      > "$DEST/taiga-db-$STAMP.dump"
    
    # Media volume (attachments, avatars)
    docker run --rm \
      -v taiga_taiga-media-data:/data:ro \
      -v "$DEST":/backup \
      alpine tar -czf "/backup/taiga-media-$STAMP.tar.gz" -C /data .
    
    # Config
    cp "$STACK/.env" "$DEST/taiga-env-$STAMP"
    chmod 600 "$DEST"/taiga-env-*
    
    find "$DEST" -type f -mtime +$KEEP_DAYS -delete

    Confirm the volume name before you trust the script:

    shell
    docker volume ls | grep taiga

    Compose prefixes volumes with the project directory name. If you cloned into /opt/taiga, the prefix is taiga_. If you cloned into taiga-docker, it is taiga-docker_.

    shell
    sudo chmod +x /usr/local/bin/taiga-backup.sh
    sudo /usr/local/bin/taiga-backup.sh
    sudo crontab -e
    shell
    30 2 * * * /usr/local/bin/taiga-backup.sh >> /var/log/taiga-backup.log 2>&1

    Push the results off the box with restic or rclone. RabbitMQ state does not need backing up. The queues are transient and rebuild on start.

    Restore

    shell
    cd /opt/taiga
    docker compose stop taiga-back taiga-async taiga-events taiga-front
    
    docker compose exec -T taiga-db dropdb -U taiga --if-exists taiga
    docker compose exec -T taiga-db createdb -U taiga taiga
    docker compose exec -T taiga-db pg_restore -U taiga -d taiga --no-owner \
      < /var/backups/taiga/taiga-db-2026-07-16-0230.dump
    
    docker run --rm \
      -v taiga_taiga-media-data:/data \
      -v /var/backups/taiga:/backup:ro \
      alpine sh -c "rm -rf /data/* && tar -xzf /backup/taiga-media-2026-07-16-0230.tar.gz -C /data"
    
    docker compose down && ./launch-taiga.sh

    Restore .env with the original SECRET_KEY, or every session and password reset token is invalidated.


    11. Upgrades

    shell
    cd /opt/taiga
    sudo /usr/local/bin/taiga-backup.sh
    
    git pull origin stable

    Reapply the two local edits git pull may clobber:

    shell
    grep -n 'docker-compose ' launch-taiga.sh taiga-manage.sh   # re-patch if present
    grep -n '9000:80' docker-compose.yml                        # re-bind to 127.0.0.1

    Then:

    shell
    docker compose pull
    docker compose down && ./launch-taiga.sh
    docker compose logs -f taiga-back

    Migrations run on taiga-back start. Watch for Applying lines and let them finish.

    Taiga's images are tagged latest in the shipped compose file. That is convenient and dangerous. Pin them explicitly if you want reproducible deploys. Edit docker-compose.yml and replace each :latest with a concrete tag from Docker Hub, then track those tags deliberately.

    Consider forking. Between the script patch, the port bind, and the tag pins, you are carrying three local modifications against a repo you git pull. Fork taiga-docker, apply your changes on a branch, and pull from upstream into it. Your future self will thank you the first time a git pull silently reverts the loopback bind.


    12. Operations

    Django management commands

    shell
    cd /opt/taiga
    ./taiga-manage.sh <command>

    Useful ones:

    shell
    ./taiga-manage.sh createsuperuser
    ./taiga-manage.sh changepassword <username>
    ./taiga-manage.sh shell
    ./taiga-manage.sh dbshell

    Reset a user password from the shell

    shell
    ./taiga-manage.sh shell
    shell
    from taiga.users.models import User
    u = User.objects.get(username='admin')
    u.set_password('NewStrongPassword1!')
    u.save()

    RabbitMQ health

    shell
    docker compose exec taiga-events-rabbitmq rabbitmqctl status
    docker compose exec taiga-async-rabbitmq rabbitmqctl list_queues

    A growing Celery queue with an idle taiga-async means the worker died. Restart it:

    shell
    docker compose restart taiga-async

    Cap Docker logs

    shell
    sudo nano /etc/docker/daemon.json
    shell
    {
      "log-driver": "json-file",
      "log-opts": { "max-size": "50m", "max-file": "3" }
    }
    shell
    sudo systemctl restart docker
    cd /opt/taiga && ./launch-taiga.sh

    13. Troubleshooting

    Frontend loads but boards never update for other users. Websockets are not connecting. Check WEBSOCKETS_SCHEME=wss, confirm the Upgrade and Connection headers are in the Nginx block, and look at the browser console for a failed /events connection.

    ./launch-taiga.sh: docker-compose: command not found. The script patch in section 4 was not applied or was reverted by a git pull.

    Login succeeds then immediately bounces back to the login page. TAIGA_SCHEME and the real scheme disagree, or TAIGA_DOMAIN does not match the hostname in the browser. Both must be exact. taiga.example.com is not the same as www.taiga.example.com.

    Django admin at /admin/ will not log in. Session cookies over plain HTTP. Get TLS working first.

    Attachments return 403 or expire instantly. ATTACHMENTS_MAX_AGE is too low, or the clock has drifted. Check timedatectl and confirm NTP sync.

    taiga-back restarts in a loop. Read the log. The two usual causes are a database it cannot reach, or a SECRET_KEY change against an existing database. The first is a network or password problem. The second is not fatal but will log everyone out.

    Everything is Up but the site 502s. The gateway is running but the frontend or backend behind it is not ready. Give the first boot ten minutes, then check docker compose logs taiga-front.

    Out of memory after a few days. The two RabbitMQ instances are the usual culprits. Check docker stats. Add swap or move to a larger plan. RabbitMQ does not shed memory gracefully.

    Changing the domain after go-live. Update TAIGA_DOMAIN in .env, recreate the stack, then fix the Django sites table:

    shell
    ./taiga-manage.sh shell
    shell
    from django.contrib.sites.models import Site
    s = Site.objects.get_current()
    s.domain = 'taiga.example.com'
    s.name = 'Taiga'
    s.save()

    Existing attachment URLs stored in project history will still reference the old domain. There is no clean fix. This is why section 2 says pick the hostname first.


    14. Where Taiga Fits

    Taiga is the right choice when your team actually runs Scrum or Kanban and wants the vocabulary to match: epics, user stories, sprints, points, burndown. The Jira and Trello importers make migration real rather than theoretical.

    It is the wrong choice if you want Gantt scheduling or budget tracking, which it does not do. Look at OpenProject for that. It is also more machine than you need if you want a single shared board, where Leantime or a lighter tool costs less to run.

    The operational cost is the nine-container footprint and the two RabbitMQ instances. Go in expecting that and Taiga is a solid, stable platform.


    Reference