Docker Compose 설치
자체 호스팅 환경에 권장하는 설치 경로입니다. scripts/install.sh 마법사가 이미지 풀, 비밀값 생성, 첫 super_admin 사용자 생성을 일괄 수행합니다. Alembic 마이그레이션은 backend 컨테이너가 기동 시 자동 적용하므로(AUTO_MIGRATE, 기본 true), 아래 두 경로 모두 수동 alembic upgrade head가 필요 없습니다. Docker 캐시가 따뜻한 상태라면 보통 10분 이내에 끝납니다.
Linux 호스트에서 sudo 권한을 가진 운영자. docker-compose와 기본 셸 사용에 익숙해야 합니다. 최종 사용자 대상은 아닙니다. 설치 완료 후 URL을 안내하세요.
사전 요구사항
- Linux 호스트 (Ubuntu 22.04 LTS, Debian 12, RHEL 9에서 검증). macOS는 개발용으로만 작동하며 프로덕션 대상이 아닙니다.
- Docker Compose.
docker-compose(V1, 하이픈)가 프로젝트 표준이며,install.sh마법사는 이를 우선 사용하되 V1이 없으면docker compose(V2) 플러그인으로 폴백하므로 최신 호스트에서도 그대로 동작합니다. V1/V2 안내 참고. openssl: SECRET_KEY와 데이터베이스 비밀번호 생성에 사용.curl: 설치 후 health 프로브(및 위의 클론 없는 빠른 설치)에 사용.- 외부 HTTPS 접근: 포털 이미지 및 Trivy DB가 게시되는 GitHub Container Registry(
ghcr.io)에 도달 가능해야 합니다. air-gapped 운영은 Trivy DB를 사내 OCI 레지스트리에 미러링하는 방식으로 지원합니다. 취약점 데이터 — Air-gapped 운영 참조. - 디스크: 이미지·workspace 마운트·최소 7일치 백업을 위해 20 GB 이상 여유.
- CPU/RAM: 최소 4 vCPU / 8 GB RAM. 실제 소스 스캔(cdxgen + scancode)은 워커에서 ~6 GB까지 사용하므로 여유를 확보해 두세요.
환경 검증:
docker-compose --version # Compose 1.x (권장)
# …V2 플러그인만 있다면 마법사가 다음으로 폴백합니다:
docker compose version # Compose v2.x
openssl version
curl --version
df -h / # 20 GB 이상 여유
평가용 설치 (dev 스택)
프로덕션 호스트를 준비하기 전에 TRUSCA를 체험하고 싶으신가요?
dev 스택(docker-compose.dev.yml)은 클론에서 포털을 띄우고 현실적인 데모
데이터를 시드합니다. 클론·마이그레이션·up·시드·로그인 계정까지의 절차는
Quickstart가 단일 기준입니다. 2 vCPU / 4 GB RAM
호스트에서 약 5분이 걸리고 실제 저장소 첫
스캔으로 끝납니다. 본 페이지는 Quickstart가
다루지 않는 것만 담습니다: 프로덕션 사양, TLS, 그리고 아래의 설치 마법사.
노트북, 일회용 클라우드 VM, 또는 2 vCPU / 4 GB RAM 호스트. 실제 배포에는 설치 마법사를 사용하세요. dev 스택은 프로덕션 하드닝(TLS, 역할 분리, 6 GB 스캔 워커)을 첫 체험의 낮은 마찰과 맞바꿉니다. 공개 인터넷에 노출하지 마세요.
취약점이 보이는 경로
시드된 데모 데이터셋은 finding을 직접 제공하며, 워커는 인터넷 egress가 있으면 첫 부팅에 Trivy DB를 다운로드합니다. Trivy와 DB는 모두 워커 컨테이너 내부에 있으므로 호스트에 외부 취약점 엔진이 필요 없습니다.
air-gapped 평가(ghcr.io egress 없음)는 취약점 데이터 — Air-gapped 운영 참조.
실제 소스 스캔(cdxgen + scancode)은 워커에서 ~6 GB까지 치솟습니다. dev 스택은 시드된 데이터 탐색용이지 프로덕션 스캔용이 아닙니다. 작은 스캔은 가능하지만 큰 레포에서는 어렵습니다. dev 스택은 TLS와 L1 DB 역할 분리도 생략하므로 공개 인터넷에 노출하지 마세요. 첫 체험 이상의 용도에는 설치 마법사를 사용하세요.
다 사용한 뒤의 정리는 Quickstart — 스택 종료를 참고하세요.
HTTPS 배포의 사전 요구사항
마법사를 실행하기 전에 호스트가 다음 세 가지 조건을 만족하는지 확인하세요. 마법사는 이를 검증하지 않으며, 하나라도 누락되면 Traefik이 조용히 실패합니다.
- DNS: 사용할 도메인(예:
oss.acme.com)의A레코드(또는CNAME)가 호스트의 공개 IP를 가리켜야 합니다.dig +short oss.acme.com으로 확인합니다. - 방화벽:
80번과443번 포트가 공개 인터넷에서 도달 가능해야 합니다. Traefik은:80에서 HTTP-01 챌린지를 사용해 Let's Encrypt 인증서를 발급하며, 성공 후에는 모든 트래픽을:443으로 리다이렉트합니다. UFW, 클라우드 제공자 방화벽, 보안 그룹 모두 두 포트가 열려 있어야 합니다. - TLS_EMAIL: 마법사는 공개 URL이
https://...일 때 이 값을 수집합니다. Let's Encrypt가 만료 경고와 레이트 리밋 상승을 이 주소로 보내므로, 실제로 확인하는 메일함을 사용하세요.
HTTP-only / localhost 설치(개발, 망분리 UAT)에는 위 셋이 모두 적용되지
않습니다 — 마법사는 TLS_EMAIL을 건너뛰고 Traefik은 ACME 흐름에 진입하지
않습니다.
빠른 설치 (클론 없이)
스택을 바로 띄우기만 하면 되고 보조 스크립트가 필요 없다면, 레포를 클론하지 않고 게시된 이미지로 곧장 설치할 수 있습니다. 파일 하나로 끝나는 설치 경험입니다. 프로덕션 이미지는 GitHub Container Registry(ghcr.io/trustedoss/trusca-backend, …/trusca-backend-worker, …/trusca-frontend)에 게시되며 익명 pull이 가능합니다.
compose 스택에 필요한 세 파일(compose 파일, env 템플릿, 1회용 Postgres 역할 초기화 스크립트)을 받고 .env를 편집한 뒤 기동합니다:
mkdir -p trustedoss && cd trustedoss
BASE=https://raw.githubusercontent.com/trustedoss/trusca/v0.22.6
# 1. 자기완결적 프로덕션 compose 파일(`build:` 섹션 없음 — ghcr.io에서 이미지 pull)
# 과 env 템플릿.
curl -fsSLO "$BASE/docker-compose.yml"
curl -fsSL "$BASE/.env.example" -o .env
# 2. compose 파일은 첫 부팅 역할 프로비저닝을 위해 레포 파일 하나를 Postgres에
# 마운트합니다. compose가 기대하는 경로로 받습니다.
mkdir -p scripts
curl -fsSL "$BASE/scripts/postgres-init.sh" -o scripts/postgres-init.sh
chmod +x scripts/postgres-init.sh
# 3. .env 편집 — 최소한 SECRET_KEY(openssl rand -hex 32), 강력한
# POSTGRES_PASSWORD / POSTGRES_APP_PASSWORD, DOMAIN, TLS_EMAIL,
# CORS_ALLOWED_ORIGINS=https://<도메인> 을 설정. 실행할 릴리스를
# IMAGE_TAG 에 지정.
$EDITOR .env
# 4. pull 후 기동.
docker-compose -f docker-compose.yml pull
docker-compose -f docker-compose.yml up -d
3번은 선택이 아닙니다. .env.example은 SECRET_KEY를 비운 채로 배포되고, APP_ENV=dev가 아닌 환경에서 backend는 이 값 없이는 기동하지 않습니다. 예전에 담겨 있던 자리표시자 문자열과 같은 방식으로 만든 값도 거부하는데, 오래된 .env가 그런 값을 들고 있을 수 있기 때문입니다. 기동 오류가 무엇이 문제인지와 고치는 명령을 함께 알려 줍니다. 이전 릴리스는 이 파일에 APP_ENV=dev를 고정해 두었고, .env로 복사되면 프로덕션 기본값을 덮어써서 이렇게 설치한 스택이 dev 모드로 돌았습니다. 그 상태에서는 위 검사가 하나도 적용되지 않았습니다.
게시된 backend 이미지의 entrypoint는 기동 시 Alembic 마이그레이션을 자동 적용(AUTO_MIGRATE, 기본 true)한 뒤 uvicorn을 시작하므로, backend가 healthy로 보고될 때 스키마는 이미 HEAD입니다. 수동 alembic upgrade head는 필요 없습니다. 다만 자동 마이그레이션은 사용자를 생성하지 않으므로, 첫 관리자는 한 번 부트스트랩합니다:
# 비밀번호를 화면에 노출하지 않고 셸 변수로 읽은 뒤, `-e` 에는 변수 "이름만"
# 넘깁니다. 값은 호출 셸에서 상속되므로 argv(`ps -ef` 노출)나 셸 히스토리에
# 남지 않습니다.
read -rs ADMIN_PASSWORD; export ADMIN_PASSWORD # 12자 이상 비밀번호 입력 후 Enter
# 첫 super_admin 생성 (스키마는 이미 HEAD).
docker-compose -f docker-compose.yml exec -T \
-e ADMIN_EMAIL=you@example.com \
-e ADMIN_PASSWORD \
backend python -m scripts.create_super_admin
unset ADMIN_PASSWORD # 사용자 생성 후 셸에서 제거
-e ADMIN_PASSWORD='리터럴'은 피하세요: 명령 실행 중 ps -ef를 실행하는
모든 사용자에게 리터럴이 노출되고 셸 히스토리에도 기록됩니다. 이름만
넘기면(-e ADMIN_PASSWORD) Docker가 환경에서 값을 상속합니다.
단일 역할 .env 템플릿은 AUTO_MIGRATE=true로 출하되며 그대로 동작합니다. L1 역할 분리 스택(DDL용 DATABASE_URL_OWNER와 런타임용 DATABASE_URL_APP 분리)에서는 런타임 컨테이너가 DML 전용 app DSN만 보유해 DDL을 실행할 수 없으므로 자동 마이그레이션을 꺼야 합니다.
- 마법사 사용 시(2단계):
install.sh가 L1을 감지(DATABASE_URL_OWNER가 설정돼 있고 런타임 DSN과 다름)하면.env에AUTO_MIGRATE=false를 자동으로 기록한 뒤 owner 역할로 직접 마이그레이션합니다. 별도로 설정할 필요가 없습니다. - 이 클론 없는 경로: 마법사가 없으므로 L1 스택에서는 운영자가 직접
.env에AUTO_MIGRATE=false를 설정하고 owner 역할로alembic upgrade head를 실행해야 합니다(그 한 명령에서DATABASE_URL을DATABASE_URL_OWNER로 덮어씀). L1 스택에서true로 두면 backend entrypoint가 명확한 DDL 권한 오류와 함께 즉시 실패(exit 1, 크래시 루프 없음)하고 로그에 원인을 남깁니다.
Liveness vs. readiness: 스택이 스키마를 기다리는 방식
backend는 인증이 필요 없는 health 엔드포인트를 두 개 노출합니다. 둘은 서로 다른 질문에 답하며, Compose / Kubernetes의 기동 게이트가 이 구분에 의존합니다.
| 엔드포인트 | 답하는 질문 | DB 접근? | 사용처 |
|---|---|---|---|
GET /health | uvicorn 프로세스가 떠서 요청을 받는가? (순수 liveness) | 아니오 | Kubernetes livenessProbe; liveness 전용 소비자 |
GET /health/ready | Postgres 스키마가 Alembic HEAD 인가, 즉 트래픽을 처리하고 워커를 띄워도 안전한가? (readiness) | 예 (alembic_version에 대한 읽기 전용 SELECT) | Compose backend healthcheck; Kubernetes readinessProbe |
/health/ready는 스키마가 HEAD와 일치할 때만 200 {"status":"ready","redis":"ok"|"degraded"}를 반환합니다. 그렇지 않으면 RFC 7807 application/problem+json 본문으로 리비전 불일치를 요약한 503을 반환하며(DSN 이나 자격 증명은 절대 노출하지 않습니다), 이때도 같은 redis 필드가 함께 들어 있습니다. 이 필드는 관측용일 뿐입니다. 로그인 시도 제한기와 요청 빈도 제한기 등 Redis를 사용하는 요청 경로 제어는 이미 Redis 장애 시 열어 두도록(fail open) 설계돼 있으므로, Redis 장애가 200 응답을 503으로 바꾸는 일은 없습니다. degraded로 표시될 때 무엇을 확인해야 하는지는 온콜 런북을 참고하십시오.
(Track B)부터 backend 서비스의 Compose healthcheck가 **/health/ready**를 검사하므로, depends_on: backend (condition: service_healthy)를 선언한 worker / beat 서비스는 스키마가 마이그레이션된 뒤에야 기동합니다. 두 토글 모두에서:
AUTO_MIGRATE=true(단일 역할 기본값): backend 컨테이너가 기동 시alembic upgrade head를 실행하고, 완료되면/health/ready가200으로 바뀝니다. 그 후 워커가 마이그레이션된 스키마 위에서 시작합니다. 일반 경로이며 운영자 조치가 필요 없습니다.AUTO_MIGRATE=false(L1 역할 분리 스택): uvicorn은/health에 즉시 응답하지만, 외부에서 owner 역할로alembic upgrade head(install.sh/upgrade.sh가 수행)를 실행해 스키마를 HEAD로 올릴 때까지/health/ready는503으로 남습니다(컨테이너는health: starting유지). 이는 의도된 동작입니다. 워커와 beat는 아직 마이그레이션되지 않은 DB 위에서 시작하는 대신 스키마를 기다립니다. L1 스택에서 마이그레이션을 깜빡하면 backend가 영영 healthy가 되지 않으니docker-compose logs backend를 확인하고 owner 역할 마이그레이션을 실행하세요.
unhealthy로 만들지 않는 이유backend healthcheck는 넉넉한 start_period(60s)를 사용합니다. 큰 DB의 첫 마이그레이션은 /health/ready가 200이 되기 전까지 한동안 걸릴 수 있는데, start_period가 그 첫 마이그레이션 완료 전에 Docker가 컨테이너를 unhealthy로 표시(및 재시작)하지 않도록 막아 줍니다.
아래 1~3단계의 install.sh 마법사는 비밀값 생성, health 대기 루프, 마이그레이션, 관리자 부트스트랩까지 위 작업을 대신 처리합니다. 또한 호스트에 V1이 없으면 Compose V2 플러그인(docker compose)으로도 동작합니다. 각 단계를 직접 제어하거나 자체 자동화를 구성할 때 클론 없는 경로를 사용하세요.
1단계 — 레포 클론
git clone https://github.com/trustedoss/trusca.git
cd trusca
포크를 운영한다면 포크 레포를 클론하세요. 재현 가능한 설치를 위해 릴리스 태그로 체크아웃합니다.
git checkout v0.22.6
2단계 — 설치 마법사 실행
bash scripts/install.sh
마법사 동작 순서:
docker-compose,openssl,curl이 PATH에 있는지 확인..env가 없으면.env.example을 복사 (있다면 백업 후 교체 옵션).- 64-hex
SECRET_KEY와 강력한 PostgreSQL 비밀번호 생성. - 포털이 노출될 공개 URL을 입력받아
.env에CORS_ALLOWED_ORIGINS와DOMAIN을 기록. - 마이그레이션 정책 결정: L1 역할 분리 스택(
DATABASE_URL_OWNER가 설정돼 있고 런타임 DSN과 다름)을 감지하면.env에AUTO_MIGRATE=false를 기록해 런타임 컨테이너가 app 역할로 DDL을 시도하지 않게 합니다. 단일 역할 스택은 기본값true유지. docker-compose pull— 고정된 이미지 풀.docker-compose up -d— 스택 기동. 단일 역할 스택에서는 backend 컨테이너가 기동 시 Alembic 마이그레이션을 자동 적용(AUTO_MIGRATE=true); L1에서는 적용하지 않습니다(앞 단계에서 정책 설정).- 백엔드
/health가 200 응답할 때까지 60초 폴링. - owner 역할(
DATABASE_URL_OWNER)로alembic upgrade head를 한 번 실행합니다. L1에서는 권위 있는 DDL 패스(런타임 컨테이너는 DML 전용 app DSN만 보유)이고, 단일 역할 스택에서 entrypoint가 이미 마이그레이션한 경우 멱등 재확인이므로 이미 적용된 리비전은 건너뜁니다. - 첫 super-admin 이메일과 비밀번호(12자 이상, 확인 입력) 입력. 자동 마이그레이션은 사용자를 만들지 않으므로 이 단계는 항상 실행됩니다.
- 최종 URL과 다음 단계 안내 출력.
정상 종료 시 출력
Installation complete
✓ TRUSCA is running at: https://trustedoss.example.com
Login: you@example.com
Admin panel: https://trustedoss.example.com/admin
API docs: https://trustedoss.example.com/api/docs
3단계 — 로그인 및 검증
- 마법사가 출력한 URL을 엽니다.
- super-admin 자격증명으로 로그인.
- /admin/health 방문. backend, postgres, redis, worker, beat 모두 녹색이어야 합니다. 워커는 첫 부팅에 Trivy DB를 다운로드(1~3분)하며, 다운로드 완료 시 Vulnerability data 카드가 녹색으로 전환됩니다.
Trivy DB 운영(갱신 주기, air-gapped 미러, 트러블슈팅)은 취약점 데이터 (Trivy DB) 참조.
4단계 — 백업 스케줄링
프로덕션에서 호스트 외부 백업은 선택이 아닙니다. cron 항목을 추가하세요.
sudo crontab -e
# m h dom mon dow command
0 3 * * * cd /opt/trustedoss-portal && bash scripts/backup.sh >> /var/log/trustedoss-backup.log 2>&1
scripts/backup.sh는 backups/<타임스탬프>/에 postgres.sql.gz, workspace.tar.gz, manifest.json을 작성합니다. 7일 이상 지난 백업은 자동 정리됩니다(.env의 BACKUP_RETENTION_DAYS로 변경).
전체 복원 절차는 백업·복원을 보세요.
스캔 용량 - worker-scan 크기 산정과 확장
Compose에는 오토스케일러가 없습니다. 큐를 지켜보다가 워커 컨테이너를 알아서 늘려 주는 계층이 없다는 뜻입니다. 이것은 의도한 선택입니다 - Compose 배포에 오케스트레이터를 얹지 않기로 했고, 그래서 운영자가 미리 용량을 산정해 두고 부족해지면 직접 늘립니다.
두 워커 서비스. S3의 큐 분리 이후 프로덕션 스택은 Celery 워커 서비스를 하나가 아니라 둘 운영합니다.
worker-scan- 스캔 파이프라인(scan_source,scan_container,ingest_sbom,scan_reachability)을 실행합니다. 이 서비스를 확장해야만 동시에 도는 스캔 수가 늘어납니다. 스캔 하나는SCAN_HARD_TIME_LIMIT_SECONDS(기본 3900초, 65분)까지 슬롯 하나를 차지할 수 있으므로, 스캔 처리량과 직결되는 것은 이 서비스의 용량입니다.worker-default- 그 외 나머지(알림, 백업, 감사 반출, 티켓 웹훅, 카탈로그 갱신 beat, 스캔 일정 폴링)를 실행합니다. 짧고 잦은 작업이라 시간 단위로 도는 스캔 뒤에 절대 줄 서면 안 됩니다. 이 서비스를 확장해도 스캔 처리량은 늘지 않습니다.
용량 계산식. 스캔 슬롯 하나는 워커 프로세스 하나가 스캔 파이프라인 하나를 동시에 처리할 수 있는 자리입니다. 슬롯 수는 다음과 같습니다.
슬롯 수 = WORKER_REPLICAS x CELERY_CONCURRENCY
WORKER_REPLICAS는 worker-scan 컨테이너를 몇 개 띄우는지입니다
(docker-compose up -d --scale worker-scan=N - 일반 Compose는
deploy.replicas를 따르지 않으므로 이렇게 지정합니다).
CELERY_CONCURRENCY는 컨테이너 하나가 스캔을 동시에 몇 건 처리하는지입니다
(.env, 기본 2 - cdxgen/scancode/Trivy 파이프라인 하나가 순간적으로
1.5~2GB를 쓰므로 이 값은 낮게 유지하세요). 스캔 평균 소요를 분 단위로
M이라 하면 슬롯의 시간당 처리량은 다음과 같습니다.
시간당 처리 스캔 수 = 슬롯 수 x 60 / M
배포 기본값(WORKER_REPLICAS=1, CELERY_CONCURRENCY=2, M ≈ 20분)에서는
슬롯 2개, 시간당 6건입니다. 배포에 들어오는 스캔 요청(푸시, PR 갱신,
예약 스캔, 웹훅 트리거를 모두 합친 것)이 이보다 많으면 스캔 큐가
쌓입니다. j번째로 도착한 스캔은 대략 floor((j-1) / 슬롯 수) x M만큼
기다린 뒤 시작합니다. 기본 슬롯 2개에 스캔 10건이 한꺼번에 몰리면
마지막 스캔은 80분 가까이 기다립니다.
확장하기. .env의 WORKER_REPLICAS를 올리고(또는 실행 시
지정하고) worker-scan 서비스를 정확히 지정해 확장하세요 -
worker-default를 확장해서는 이 문제가 풀리지 않습니다.
docker-compose -f docker-compose.yml up -d --scale worker-scan=4
레플리카를 늘리는 대신 CELERY_CONCURRENCY를 올려도 되지만, 레플리카
쪽을 우선하세요. 컨테이너를 늘리면 cdxgen/scancode/Trivy가 쓰는
메모리가 여러 cgroup에 나뉘어 실리지만, 동시성만 올리면 한 컨테이너에
몰립니다. worker-default도 같은 방식으로 확장합니다
(--scale worker-default=N). 이 서비스는 작업이 짧아 드물지만, 느린
티켓 웹훅이나 멈춘 백업처럼 하류 연동이 막히면 여기도 밀릴 수
있습니다.
beat은 절대 늘리지 마세요확장 대상은 워커이지 스케줄러가 아닙니다. beat은 프로세스가 정확히
하나여야 합니다. Celery beat은 락을 잡지 않아서 beat 프로세스마다 자기
시계로 전체 스케줄을 발화합니다. 두 개가 뜨면 모든 주기 작업이 두 번씩
큐에 들어가고, 카탈로그 갱신과 보관 정리, 스캔 일정 폴링이 전부 두 배로
돕니다. 오류도 경고도 나지 않으므로 눈에 띄는 증상은 같은 일이 두 번
일어난다는 것뿐입니다.
겉보기가 다른 두 실수가 여기에 해당합니다. 하나는 --scale beat=2처럼
드러나는 경우입니다. 다른 하나는 같은 데이터베이스와 브로커를 바라보는
두 번째 호스트에서 이 compose 파일을 함께 띄우는 경우로, 호스트마다는
하나로 보이지만 스케줄러는 둘입니다. 가용성 때문에 예비 스케줄러가
필요하다면 RedBeat 같은 락을 쓰는 스케줄러가 있어야 하며, 이 배포는
그것을 포함하지 않습니다.
언제 확장해야 하는지 알기. QUEUE_BACKLOG_METRICS_ENABLED를
켜면(/metrics에 trusca_broker_queue_backlog와
trusca_scan_queue_wait_seconds가 노출됩니다)
QUEUE_BACKLOG_ALERT_ENABLED를 함께 켤 수 있습니다. 큐 하나가
임계값을 넘은 채 일정 시간 지속되면 Slack/Teams로 알립니다(새 채널이
아니라 기존 알림 채널을 그대로 씁니다). 둘 다 기본은 꺼짐입니다.
임계값·쿨다운 설정은 환경변수 - 큐 적체 알림을,
알림이 왔을 때 할 일은 온콜 런북을
보세요.
종단 간 첫 성공 체크리스트 (30분)
bash scripts/install.sh 완료 후:
-
https://<your-host>열기 — 로그인 화면이 렌더링되고 브라우저가 유효한 TLS 자물쇠를 표시(HTTPS 인 경우). - 마법사가 출력한 super-admin 이메일·비밀번호로 로그인.
- 워커가 첫 Trivy DB 다운로드를 마치기를 대기 —
docker-compose -f docker-compose.yml logs --tail=100 worker | grep trivy_db가 첫 부팅 후 1~3분 내에trivy_db_download_complete를 보여줍니다. 완료 전엔 새 스캔의 Vulnerabilities 탭이 비어 있습니다. -
/admin/teams→ New team으로 이동 → 이름을engineering으로 설정. - 동료에게
/register에서 가입을 요청한 뒤,/admin/users → <user> → Memberships → Add to team