diff --git a/productivity/taiga/.env.example b/productivity/taiga/.env.example index 7fa8b0d..5758249 100644 --- a/productivity/taiga/.env.example +++ b/productivity/taiga/.env.example @@ -1,21 +1,42 @@ -DOMAIN=taiga.example.com -TAIGA_SECRET_KEY=change-this-to-a-secure-random-string -TAIGA_ADMIN_USERNAME=admin -TAIGA_ADMIN_EMAIL=admin@example.com -TAIGA_ADMIN_PASSWORD=admin +DOMAIN= +SECRET_KEY= +TAIGA_SCHEME=https +WEBSOCKETS_SCHEME=wss +SUBPATH= + +# Admin credentials (not used in docker-compose; set via manage.py createsuperuser) +TAIGA_ADMIN_USERNAME= +TAIGA_ADMIN_EMAIL= +TAIGA_ADMIN_PASSWORD= # Postgres -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -POSTGRES_USER=taiga_user -POSTGRES_PASSWORD=securepassword POSTGRES_DB=taiga +POSTGRES_USER= +POSTGRES_PASSWORD= -# RabbitMQ -RABBITMQ_HOST=rabbitmq -RABBITMQ_USER=taiga_user -RABBITMQ_PASSWORD=securepassword +# Async RabbitMQ (used by Celery workers) +TAIGA_ASYNC_RABBITMQ_USER= +TAIGA_ASYNC_RABBITMQ_PASSWORD= +TAIGA_ASYNC_RABBITMQ_VHOST=taiga +TAIGA_ASYNC_RABBITMQ_HOST=taiga-async-rabbitmq -CELERY_ENABLED=False -CELERY_BROKER=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@${RABBITMQ_HOST}:5672/ -CELERY_ALWAYS_EAGER=False +# Events RabbitMQ (used by taiga-events WebSocket service) +TAIGA_EVENTS_RABBITMQ_USER= +TAIGA_EVENTS_RABBITMQ_PASSWORD= +TAIGA_EVENTS_RABBITMQ_VHOST=taiga +TAIGA_EVENTS_RABBITMQ_HOST=taiga-events-rabbitmq + +# Shared RabbitMQ Erlang cookie (any secret string) +RABBITMQ_ERLANG_COOKIE= + +# Email (set EMAIL_BACKEND=console to log to stdout instead of sending) +EMAIL_BACKEND=console +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=True +EMAIL_USE_SSL=False + +# Telemetry +ENABLE_TELEMETRY=False diff --git a/productivity/taiga/README.md b/productivity/taiga/README.md index ae4a9d7..1fb07c7 100644 --- a/productivity/taiga/README.md +++ b/productivity/taiga/README.md @@ -1,12 +1,65 @@ # Taiga Open source Agile project management tool. -## Setup -1. Fill out `.env` from the example. You are supposed to be able to set admin credentials here but I had issues with that. Use a util like `openssl` for key generation. -2. Setup credentials using python util: +## Architecture +Traffic flows: **Traefik → taiga-gateway (nginx) → internal services** + +- `taiga-gateway` — nginx reverse proxy; the only service on the Traefik network +- `taiga-back` — Django backend API +- `taiga-async` — Celery worker (same image as back, different entrypoint) +- `taiga-front` — Angular frontend +- `taiga-events` — WebSocket events service +- `taiga-protected` — Protected media token verification +- `taiga-db` — PostgreSQL +- `taiga-async-rabbitmq` — RabbitMQ for Celery async tasks +- `taiga-events-rabbitmq` — RabbitMQ for real-time WebSocket events + +## Setup +1. Copy `.env.example` to `.env` and fill out all values. Use `openssl rand -base64 32` for key/password generation. +2. Start the stack: `docker compose up -d` +3. Create the admin user: ``` docker exec -it taiga-back python3 manage.py createsuperuser --username admin --email admin@example.com ``` + +## Runbook + +**Start / stop** ``` +docker compose up -d +docker compose down ``` -Note: I had issues doing this call while RabbitMQ was enabled via this Celery util. I set `CELERY_ENABLED=false` to get the call to work. This is supposed to have us use some slower utility as an alternative. AI assured me I could re-enable after the fact but I did not just to be safe. + +**View logs** +``` +docker compose logs -f # all services +docker compose logs -f taiga-back # one service +``` + +**Restart a single service** +``` +docker compose restart taiga-back +``` + +**Create or reset admin user** +``` +docker exec -it taiga-back python3 manage.py createsuperuser +``` + +**Update images** +``` +docker compose pull +docker compose up -d +``` + +**Database backup / restore** +``` +docker exec taiga-db pg_dump -U taiga_user taiga > backup.sql +docker exec -i taiga-db psql -U taiga_user taiga < backup.sql +``` + +## Notes +- Two separate RabbitMQ instances are required: one for Celery async tasks, one for WebSocket events. +- `EMAIL_USE_TLS` and `EMAIL_USE_SSL` are mutually exclusive — only set one to `True`. +- `EMAIL_BACKEND=console` logs emails to stdout (useful for testing). Switch to `smtp` for real email. +- Static files are stored in the `taiga-static` named Docker volume and shared between `taiga-back`, `taiga-async`, and `taiga-gateway`. diff --git a/productivity/taiga/docker-compose.yml b/productivity/taiga/docker-compose.yml index 69650ab..6567aae 100644 --- a/productivity/taiga/docker-compose.yml +++ b/productivity/taiga/docker-compose.yml @@ -4,58 +4,42 @@ networks: taiga: driver: bridge +volumes: + taiga-static: + taiga-async-rabbitmq-data: + taiga-events-rabbitmq-data: + +# Shared environment for taiga-back and taiga-async (Celery worker) +x-environment: + &default-back-environment + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_HOST: taiga-db + TAIGA_SECRET_KEY: ${SECRET_KEY} + TAIGA_SITES_SCHEME: ${TAIGA_SCHEME} + TAIGA_SITES_DOMAIN: ${DOMAIN} + TAIGA_SUBPATH: ${SUBPATH} + DEFAULT_FROM_EMAIL: ${EMAIL_HOST_USER} + EMAIL_BACKEND: "django.core.mail.backends.${EMAIL_BACKEND}.EmailBackend" + EMAIL_USE_TLS: ${EMAIL_USE_TLS} + EMAIL_USE_SSL: ${EMAIL_USE_SSL} + EMAIL_HOST: ${EMAIL_HOST} + EMAIL_PORT: ${EMAIL_PORT} + EMAIL_HOST_USER: ${EMAIL_HOST_USER} + EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} + CELERY_ENABLED: "True" + RABBITMQ_USER: ${TAIGA_ASYNC_RABBITMQ_USER} + RABBITMQ_PASS: ${TAIGA_ASYNC_RABBITMQ_PASSWORD} + RABBITMQ_VHOST: ${TAIGA_ASYNC_RABBITMQ_VHOST} + RABBITMQ_HOST: taiga-async-rabbitmq + ENABLE_TELEMETRY: ${ENABLE_TELEMETRY} + services: - taiga-front: - image: taigaio/taiga-front:latest - container_name: taiga-front - restart: always - environment: - - TAIGA_API_URL=https://${DOMAIN}/api/v1 - - TAIGA_EVENTS_URL=wss://${DOMAIN}/events - networks: - - taiga - - traefik - depends_on: - - taiga-back - labels: - - traefik.http.routers.taiga.rule=Host(`${DOMAIN}`) - - traefik.http.routers.taiga.tls=true - - traefik.http.routers.taiga.tls.certresolver=lets-encrypt - - traefik.http.services.taiga.loadbalancer.server.port=80 - taiga-back: - image: taigaio/taiga-back:latest - container_name: taiga-back - restart: always - env_file: - - .env - volumes: - - /pwspool/software/taiga/back/data:/taiga-back/data - - /pwspool/software/taiga/back/media:/taiga-back/media - networks: - - taiga - - traefik - depends_on: - - postgres - - rabbitmq - - taiga-events - labels: - - traefik.http.routers.taiga-back.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`) - - traefik.http.routers.taiga-back.tls=true - - traefik.http.routers.taiga-back.tls.certresolver=lets-encrypt - - traefik.http.services.taiga-back.loadbalancer.server.port=8000 + # ── Database ──────────────────────────────────────────────────────────────── - taiga-events: - image: taigaio/taiga-events:latest - container_name: taiga-events - restart: always - environment: - - TAIGA_SECRET=${TAIGA_SECRET_KEY} - - TAIGA_BACK_HOST=http://taiga-back:8000 - networks: - - taiga - - postgres: + taiga-db: image: postgres:13 container_name: taiga-db restart: always @@ -67,14 +51,156 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - /pwspool/software/taiga/db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 5 - rabbitmq: - image: rabbitmq:3-management - container_name: taiga-rabbitmq + # ── Backend ───────────────────────────────────────────────────────────────── + + taiga-back: + image: taigaio/taiga-back:latest + container_name: taiga-back + restart: always + environment: + <<: *default-back-environment + networks: + - taiga + depends_on: + taiga-db: + condition: service_healthy + taiga-async-rabbitmq: + condition: service_healthy + volumes: + - taiga-static:/taiga-back/static-root + - /pwspool/software/taiga/back/media:/taiga-back/media + + taiga-async: + image: taigaio/taiga-back:latest + container_name: taiga-async + entrypoint: ["/taiga-back/docker/async_entrypoint.sh"] + restart: always + environment: + <<: *default-back-environment + networks: + - taiga + depends_on: + taiga-db: + condition: service_healthy + taiga-async-rabbitmq: + condition: service_healthy + taiga-back: + condition: service_started + volumes: + - taiga-static:/taiga-back/static-root + - /pwspool/software/taiga/back/media:/taiga-back/media + + # ── Async RabbitMQ (Celery tasks) ─────────────────────────────────────────── + + taiga-async-rabbitmq: + image: rabbitmq:3.8-management-alpine + container_name: taiga-async-rabbitmq restart: always networks: - taiga environment: - - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER} - - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD} + RABBITMQ_DEFAULT_USER: ${TAIGA_ASYNC_RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${TAIGA_ASYNC_RABBITMQ_PASSWORD} + RABBITMQ_DEFAULT_VHOST: ${TAIGA_ASYNC_RABBITMQ_VHOST} + RABBITMQ_ERLANG_COOKIE: ${RABBITMQ_ERLANG_COOKIE} + volumes: + - taiga-async-rabbitmq-data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_running", "-q"] + interval: 30s + timeout: 10s + retries: 5 + # ── Frontend ───────────────────────────────────────────────────────────────── + + taiga-front: + image: taigaio/taiga-front:latest + container_name: taiga-front + restart: always + environment: + TAIGA_URL: "${TAIGA_SCHEME}://${DOMAIN}" + TAIGA_WEBSOCKETS_URL: "${WEBSOCKETS_SCHEME}://${DOMAIN}" + TAIGA_SUBPATH: "${SUBPATH}" + networks: + - taiga + depends_on: + - taiga-back + + # ── Events (WebSocket) ─────────────────────────────────────────────────────── + + taiga-events: + image: taigaio/taiga-events:latest + container_name: taiga-events + restart: always + environment: + RABBITMQ_URL: "amqp://${TAIGA_EVENTS_RABBITMQ_USER}:${TAIGA_EVENTS_RABBITMQ_PASSWORD}@taiga-events-rabbitmq:5672/${TAIGA_EVENTS_RABBITMQ_VHOST}" + TAIGA_SECRET_KEY: ${SECRET_KEY} + networks: + - taiga + depends_on: + taiga-events-rabbitmq: + condition: service_healthy + + taiga-events-rabbitmq: + image: rabbitmq:3.8-management-alpine + container_name: taiga-events-rabbitmq + restart: always + networks: + - taiga + environment: + RABBITMQ_DEFAULT_USER: ${TAIGA_EVENTS_RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${TAIGA_EVENTS_RABBITMQ_PASSWORD} + RABBITMQ_DEFAULT_VHOST: ${TAIGA_EVENTS_RABBITMQ_VHOST} + RABBITMQ_ERLANG_COOKIE: ${RABBITMQ_ERLANG_COOKIE} + volumes: + - taiga-events-rabbitmq-data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_running", "-q"] + interval: 30s + timeout: 10s + retries: 5 + + # ── Protected media service ────────────────────────────────────────────────── + + taiga-protected: + image: taigaio/taiga-protected:latest + container_name: taiga-protected + restart: always + environment: + SECRET_KEY: ${SECRET_KEY} + MAX_AGE: 360 + networks: + - taiga + volumes: + - /pwspool/software/taiga/back/media:/taiga-back/media + + # ── Gateway (nginx, the only service exposed to Traefik) ──────────────────── + + taiga-gateway: + image: nginx:alpine + container_name: taiga-gateway + restart: always + networks: + - taiga + - traefik + depends_on: + - taiga-front + - taiga-back + - taiga-events + - taiga-protected + volumes: + - taiga-static:/taiga/static:ro + - /pwspool/software/taiga/back/media:/taiga/media:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + labels: + - traefik.enable=true + - traefik.http.routers.taiga.rule=Host(`${DOMAIN}`) + - traefik.http.routers.taiga.tls=true + - traefik.http.routers.taiga.tls.certresolver=lets-encrypt + - traefik.http.services.taiga.loadbalancer.server.port=80 diff --git a/productivity/taiga/nginx.conf b/productivity/taiga/nginx.conf new file mode 100644 index 0000000..58c5da5 --- /dev/null +++ b/productivity/taiga/nginx.conf @@ -0,0 +1,81 @@ +server { + listen 80 default_server; + + client_max_body_size 100M; + charset utf-8; + + # Frontend + location / { + proxy_pass http://taiga-front/; + proxy_pass_header Server; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # API + location /api/ { + proxy_pass http://taiga-back:8000/api/; + proxy_pass_header Server; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Admin + location /admin/ { + proxy_pass http://taiga-back:8000/admin/; + proxy_pass_header Server; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Events (WebSocket) + location /events { + proxy_pass http://taiga-events:8888/events; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # Protected media (internal redirect target) + location /_protected { + internal; + alias /taiga/media; + } + + # Public exports + location /media/exports/ { + alias /taiga/media/exports/; + add_header Content-Disposition 'attachment'; + } + + # Media (proxied through taiga-protected for token verification) + location /media/ { + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://taiga-protected:8003/; + proxy_redirect off; + } + + # Static assets + location /static/ { + alias /taiga/static/; + } +}