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:
| Container | Role |
|---|---|
taiga-db | PostgreSQL |
taiga-back | Django API |
taiga-async | Celery worker |
taiga-async-rabbitmq | Broker for Celery |
taiga-front | Angular frontend served by Nginx |
taiga-events | Node websocket service |
taiga-events-rabbitmq | Broker for events |
taiga-protected | Signed URL service for attachments |
taiga-gateway | Internal 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
| Users | vCPU | RAM | Disk |
|---|---|---|---|
| Evaluation / small team, up to 10 | 2 | 4 GB | 40 GB |
| 10 to 40 active | 2 to 4 | 6 to 8 GB | 60 GB |
| 40+ with heavy attachments | 4 | 8 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.compointing 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.
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 nginxSwap
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/fstabFirewall
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 enable3. Install Docker Engine
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 version4. Clone taiga-docker
Use the stable branch. The main branch is for development.
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 stableThe 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:
grep -rn 'docker-compose ' launch-taiga.sh launch-all.sh taiga-manage.shPatch them:
sed -i 's/docker-compose /docker compose /g' \
launch-taiga.sh launch-all.sh taiga-manage.sh
chmod +x launch-taiga.sh taiga-manage.shAlternatively install the compatibility shim and leave the scripts alone:
sudo apt -y install docker-compose-v2
sudo ln -s /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-composePatching 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.
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 .envSet:
# 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=Falsechmod 600 .envNotes 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.
nano /opt/taiga/docker-compose.ymlFind the taiga-gateway service and change:
ports:
- "9000:80"to:
ports:
- "127.0.0.1:9000:80"6. First Boot
cd /opt/taiga
./launch-taiga.shThe first run pulls every image and runs Django migrations. Expect five to ten minutes on a modest plan.
Watch it:
docker compose logs -f taiga-backCheck all nine containers:
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:
cd /opt/taiga
./taiga-manage.sh createsuperuserThis 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:
curl -I http://127.0.0.1:9000/7. Nginx Reverse Proxy and TLS
Issue the certificate:
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-emailAdd the websocket upgrade map:
sudo nano /etc/nginx/conf.d/upgrade-map.confmap $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Write the server block:
sudo nano /etc/nginx/sites-available/taigaserver {
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:
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 nginxAdd the renewal deploy hook:
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-runBrowse 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:
PUBLIC_REGISTER_ENABLED=Truecd /opt/taiga
docker compose down && ./launch-taiga.shNote 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 /:
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:
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.comRecreate:
cd /opt/taiga
docker compose down && ./launch-taiga.shTest from a Django shell:
./taiga-manage.sh shellfrom 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:
docker compose logs taiga-back | grep -A 20 'Subject:'10. Backups
Three things matter: PostgreSQL, the media volume, and .env.
sudo nano /usr/local/bin/taiga-backup.sh#!/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 -deleteConfirm the volume name before you trust the script:
docker volume ls | grep taigaCompose 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_.
sudo chmod +x /usr/local/bin/taiga-backup.sh
sudo /usr/local/bin/taiga-backup.sh
sudo crontab -e30 2 * * * /usr/local/bin/taiga-backup.sh >> /var/log/taiga-backup.log 2>&1Push 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
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.shRestore .env with the original SECRET_KEY, or every session and password reset token is invalidated.
11. Upgrades
cd /opt/taiga
sudo /usr/local/bin/taiga-backup.sh
git pull origin stableReapply the two local edits git pull may clobber:
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.1Then:
docker compose pull
docker compose down && ./launch-taiga.sh
docker compose logs -f taiga-backMigrations 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
cd /opt/taiga
./taiga-manage.sh <command>Useful ones:
./taiga-manage.sh createsuperuser
./taiga-manage.sh changepassword <username>
./taiga-manage.sh shell
./taiga-manage.sh dbshellReset a user password from the shell
./taiga-manage.sh shellfrom taiga.users.models import User
u = User.objects.get(username='admin')
u.set_password('NewStrongPassword1!')
u.save()RabbitMQ health
docker compose exec taiga-events-rabbitmq rabbitmqctl status
docker compose exec taiga-async-rabbitmq rabbitmqctl list_queuesA growing Celery queue with an idle taiga-async means the worker died. Restart it:
docker compose restart taiga-asyncCap Docker logs
sudo nano /etc/docker/daemon.json{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "3" }
}sudo systemctl restart docker
cd /opt/taiga && ./launch-taiga.sh13. 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:
./taiga-manage.sh shellfrom 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
- taiga-docker repository: https://github.com/kaleidos-ventures/taiga-docker
- Taiga documentation: https://docs.taiga.io/
- Taiga resources and support: https://resources.taiga.io/
