공식 mattermost/docker 저장소를 Ubuntu 리눅스 서버에 설치하고, nginx 리버스 프록시 + Let's Encrypt(HTTPS)로 https://<도메인> 서비스까지 올린 실제 과정을 정리한 문서입니다. 공식 저장소: https://github.com/mattermost/docker

이 글의 실제 예시 환경

  • 서버: Ubuntu (예: your-server), amd64
  • 저장소 경로: ~/docker/
  • 도메인: mattermost.example.com
  • Compose 구조: docker-compose.yml(base) + override 파일 택1
  • 서비스: postgres + mattermost + nginx (3개 컨테이너)

0. 개요 — Compose 파일 구조 (base + override)

이 저장소는 base + override 패턴을 씁니다.

파일 역할
docker-compose.yml base. postgres + mattermost 정의. 포트 발행(ports:) 없음
docker-compose.without-nginx.yml override. APP_PORT:8065를 호스트에 직접 노출 (localhost/프록시 뒤 접속용)
docker-compose.nginx.yml override. nginx 컨테이너를 붙여 80/443으로 HTTPS 서비스

핵심: base 파일만 docker compose up 하면 mattermost는 컨테이너 내부에서만 8065를 열고 호스트로는 노출되지 않습니다. override를 반드시 함께 지정해야 합니다.

두 override는 "동시 사용"이 아니라 "택일"입니다.

  • nginx 버전 — 서버 앞단에 아무 프록시도 없을 때. nginx가 443/80을 열고 TLS 종료 처리.
  • without-nginx 버전 — 앞단에 이미 nginx/Traefik/클라우드 LB가 있어서 그쪽이 HTTPS를 담당할 때.

이 문서는 단독 서버 + nginx 버전(가장 흔한 케이스)을 기준으로 합니다.

최소 경로: 1 → 2 → 3 → 4 → 5 → 6


1. 사전 준비 (필수)

1-1. Docker 설치 확인

docker --version
docker compose version

없으면 Docker 공식 설치:

curl -fsSL https://get.docker.com | sh

M1 Mac과의 차이: Ubuntu(amd64)에서는 platform: linux/amd64 에뮬레이션이 필요 없습니다. 이미지가 네이티브로 돕니다. compose 파일에 platform: linux/amd64가 남아 있어도 amd64 서버에서는 무해하니 그대로 둬도 됩니다.

1-2. 도메인 · DNS · 방화벽 (HTTPS의 핵심 전제)

Let's Encrypt 발급이 되려면 아래가 반드시 충족돼야 합니다.

  1. 공인 도메인이 있을 것 (localhost 불가)
  2. 도메인의 DNS A레코드가 이 서버의 공인 IP를 가리킬 것
  3. 서버의 80·443 포트가 외부 개방돼 있을 것

확인:

# 도메인이 가리키는 IP
dig +short mattermost.example.com

# 이 서버의 공인 IP — 위 결과와 같아야 함
curl -s ifconfig.me

방화벽(ufw 사용 시):

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 8443/udp   # Calls(음성/화상) 기능 사용 시
sudo ufw allow 8443/tcp

클라우드(AWS/GCP/Oracle 등)라면 보안 그룹/방화벽 규칙에서도 80·443을 열어야 합니다.


2. .env 설정

docker/ 폴더의 .env에서 도메인 관련 값을 실제 값으로 맞춥니다.

DOMAIN=mattermost.example.com
MM_SERVICESETTINGS_SITEURL=https://mattermost.example.com

HTTPS_PORT=443
HTTP_PORT=80
CALLS_PORT=8443

DOMAINlocalhost로 남아 있으면 HTTPS·SiteURL이 모두 깨집니다. 반드시 실제 도메인으로 바꾸세요. 인증서(CERT_PATH/KEY_PATH) 경로는 3번에서 발급 후 맞춥니다.


3. Let's Encrypt 인증서 발급

nginx 컨테이너는 .envCERT_PATH/KEY_PATH에 있는 인증서를 마운트합니다. 이 파일이 없거나 형식이 깨지면 nginx가 아래 에러로 기동 실패(재시작 루프) 합니다.

nginx: [emerg] cannot load certificate "/cert.pem":
PEM_read_bio_X509_AUX() failed
(SSL: error:0480006C:PEM routines::no start line:Expecting: TRUSTED CERTIFICATE)

read-only file system 경고는 정상입니다(컨테이너가 read-only). 진짜 원인은 인증서입니다.

3-1. 발급 스크립트 사용법 확인

cd ~/docker
head -50 scripts/issue-certificate.sh

usage 요약:

Usage: issue-certificate.sh [-h] <-d DOMAIN> <-o PATH>
  -d  인증서를 발급할 도메인   (예: mattermost.example.com)
  -o  인증서를 저장할 출력 경로 (반드시 절대경로, 예: ${PWD}/certs)

⚠️ 자주 하는 실수: -o출력 경로(PATH) 입니다. 이메일이 아닙니다. -o에 이메일을 넣으면 아래 에러가 납니다. includes invalid characters for a local volume name ... use absolute path 이메일은 발급 도중 certbot이 대화형으로 물어봅니다.

3-2. 발급 실행

cd ~/docker

# nginx 컨테이너가 80을 점유 중이면 먼저 내림 (certbot이 80으로 도메인 검증)
docker compose -f docker-compose.yml -f docker-compose.nginx.yml stop nginx

# -o 는 절대경로. ${PWD}=/root/docker 이므로 .env의 ./certs 와 일치
bash scripts/issue-certificate.sh -d mattermost.example.com -o ${PWD}/certs

실행 중 certbot이 대화형으로 물어봅니다:

  1. Enter email address → 관리자 이메일 입력
  2. Agree to Terms of Service? (Y)Y
  3. Share email with EFF? (Y/N) → 아무거나 (N 무방)

성공 시:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/mattermost.example.com/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/mattermost.example.com/privkey.pem
This certificate expires on 2026-10-11.

위 경로는 컨테이너 내부 기준입니다. 호스트에서는 ~/docker/certs/etc/letsencrypt/live/mattermost.example.com/ 에 저장됩니다.

호스트에서 확인:

ls -l ~/docker/certs/etc/letsencrypt/live/mattermost.example.com/

4. .env의 인증서 경로를 발급 결과로 교체

.env에서 CERT_PATH/KEY_PATH를 방금 발급된 경로로 바꿉니다. (기존 self-signed용 ./volumes/web/cert/... 줄은 주석 처리 또는 교체)

CERT_PATH=./certs/etc/letsencrypt/live/mattermost.example.com/fullchain.pem
KEY_PATH=./certs/etc/letsencrypt/live/mattermost.example.com/privkey.pem

cert.pem이 아니라 fullchain.pem을 쓰는 게 중요합니다. fullchain은 서버 인증서 + 중간 인증서(체인)를 합친 파일이라, 이게 없으면 일부 브라우저/앱에서 "인증서 체인 불완전" 에러가 납니다.


5. nginx 버전으로 기동

cd ~/docker
docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
docker compose -f docker-compose.yml -f docker-compose.nginx.yml logs -f nginx

.env 경로를 바꿨으므로 restart가 아니라 up -d(컨테이너 재생성) 로 볼륨 마운트를 다시 잡는 게 안전합니다.

[emerg] cannot load certificate 없이 nginx가 조용히 떠 있으면 성공입니다.

접속 테스트:

curl -I https://mattermost.example.com

브라우저에서 https://mattermost.example.com 접속 → 자물쇠(🔒)와 함께 Mattermost 로그인 화면이 뜨면 HTTPS까지 완료입니다.


6. 계정 / 관리자 (mmctl)

6-1. "회원가입 링크가 안 보인다"는 정상

  • 계정 0개인 새 서버 → 루트 접속 시 "첫 계정 만들기" 화면
  • 계정이 1개라도 있음 → 로그인 화면만 나오고, 기본값 EnableOpenServer: false 때문에 "Create account" 링크가 숨겨짐

주의 — 데이터는 재설치해도 남는다: docker compose ... down 은 컨테이너만 지우고 데이터 볼륨(./volumes + postgres)은 보존합니다. 그래서 without-nginx로 설치 → 삭제 → nginx로 재설치해도 기존 유저가 그대로 살아있습니다. 루트 접속이 로그인으로 리다이렉트되면 = 이미 계정이 있다는 뜻입니다.

6-2. 현재 유저 확인

로그인/비밀번호 없이 컨테이너 내부 관리 채널(mmctl --local)로 확인합니다.

cd ~/docker
docker compose -f docker-compose.yml -f docker-compose.nginx.yml exec mattermost \
  mmctl --local user list

예시 출력 해석:

유저 성격
calls@localhost, playbooks@localhost, system-bot@localhost 내장 봇 (플러그인용, 건드리지 말 것)
member1@example.com, member2@example.com 실제 사람 계정

특정 유저가 관리자인지 확인 (roles에 system_admin 있으면 관리자):

docker compose -f docker-compose.yml -f docker-compose.nginx.yml exec mattermost \
  mmctl --local user search <이메일_또는_유저명>

6-3. 관리자 계정 추가 (기존 계정은 그대로 두고)

docker compose -f docker-compose.yml -f docker-compose.nginx.yml exec mattermost \
  mmctl --local user create \
  --email admin@mattermost.example.com \
  --username admin \
  --password '<강한-비밀번호>' \
  --system-admin
  • --system-admin 이 붙어야 시스템 관리자로 생성됩니다.
  • 비밀번호는 기본 정책상 대문자+소문자+숫자 정도는 포함해야 통과됩니다.

생성 확인 후 https://mattermost.example.com/login 에서 로그인:

docker compose -f docker-compose.yml -f docker-compose.nginx.yml exec mattermost \
  mmctl --local user search admin@mattermost.example.com

6-4. 기타 계정 관리

# 기존 유저를 관리자로 승격
docker compose -f docker-compose.yml -f docker-compose.nginx.yml exec mattermost \
  mmctl --local user promote <이메일>

# 비밀번호 리셋 (비번 분실 시)
docker compose -f docker-compose.yml -f docker-compose.nginx.yml exec mattermost \
  mmctl --local user change-password <이메일> --password '새비번123!'

mmctl --local 은 컨테이너 안에서 인증 없이 도는 관리 채널입니다. 서버 접근 권한 = 최고 관리 권한이므로, 비번을 몰라도 계정을 부트스트랩/복구할 수 있습니다.


7. 두 번째 사용자 추가 방법

첫 관리자 이후 기본값(EnableOpenServer: false)에서는 자유 가입이 막혀 있습니다.

  • 방법 1 (권장, 내부용): 관리자 로그인 → 팀 이름 옆 ⋮ → Invite People → 초대 링크
  • 방법 2: System Console → User Management → Users → Add User
  • 방법 3 (공개 서비스): System Console → Site Configuration → Users and Teams → Enable Open Server → true (로그인 화면에 "Create account" 링크가 다시 생김. 스팸 가입 주의)

8. 운영 명령 (참고)

# 상태 확인
docker compose -f docker-compose.yml -f docker-compose.nginx.yml ps

# 로그
docker compose -f docker-compose.yml -f docker-compose.nginx.yml logs -f mattermost
docker compose -f docker-compose.yml -f docker-compose.nginx.yml logs -f nginx

# 재시작 / 종료 (down은 데이터 볼륨 보존)
docker compose -f docker-compose.yml -f docker-compose.nginx.yml restart nginx
docker compose -f docker-compose.yml -f docker-compose.nginx.yml down

명령이 길어 번거로우면 alias 등록:

echo "alias mmc='docker compose -f docker-compose.yml -f docker-compose.nginx.yml'" >> ~/.bashrc
source ~/.bashrc
# 이후: mmc up -d / mmc ps / mmc logs -f nginx / mmc exec mattermost mmctl --local user list

9. 인증서 자동 갱신 (webroot 무중단 방식)

Let's Encrypt 인증서는 90일 만료입니다. docker-compose.nginx.yml은 무중단 갱신을 위한 shared-webroot 볼륨과 고정 네트워크 이름(mattermost)을 이미 정의해 두었습니다. 아래는 실제로 검증한 webroot 방식 구성입니다 (nginx를 켜둔 채 갱신 → 다운타임 없음).

갱신 방식 비교

방식 동작 다운타임
webroot (이 문서 채택) certbot이 검증 파일을 shared-webroot에 쓰고 nginx가 서빙 없음
standalone nginx를 잠깐 내리고 certbot이 80포트 직접 사용 있음(수 초)

9-1. 갱신 설정이 webroot인지 확인

scripts/issue-certificate.sh로 발급하면 갱신 설정이 webroot로 저장됩니다. 확인:

grep authenticator /root/docker/certs/etc/letsencrypt/renewal/mattermost.example.com.conf
# authenticator = webroot  가 나와야 함

standalone으로 돼 있으면 webroot로 전환:

sed -i 's/^authenticator = standalone/authenticator = webroot/' \
  /root/docker/certs/etc/letsencrypt/renewal/mattermost.example.com.conf
cat >> /root/docker/certs/etc/letsencrypt/renewal/mattermost.example.com.conf <<'EOF'
webroot_path = /usr/share/nginx/html,
[[webroot_map]]
mattermost.example.com = /usr/share/nginx/html
EOF

9-2. ⚠️ nginx가 ACME 검증 경로를 서빙하도록 (가장 중요)

Mattermost 기본 nginx conf는 80포트 요청을 전부 https로 301 리다이렉트합니다. 그러면 Let's Encrypt가 평문 http로 검증 파일(/.well-known/acme-challenge/)을 못 가져와 갱신이 멈춘 것처럼 됩니다. acme-challenge 경로만 리다이렉트에서 빼줘야 합니다.

nginx 함정: return 301server 블록 바로 아래(location 밖)에 있으면 location이 아예 평가되지 않아, acme location을 추가해도 소용없습니다. 리다이렉트를 location / 안으로 옮겨야 합니다.

/root/docker/nginx/conf.d/default.conflisten 80 server 블록을 이 형태로 만듭니다:

server {
    listen 80;
    server_name mattermost.example.com;

    # ACME 검증만 예외 — 평문 http로 서빙 (리다이렉트보다 우선)
    location ^~ /.well-known/acme-challenge/ {
        root /usr/share/nginx/html;
        default_type "text/plain";
    }

    # 나머지는 https로 리다이렉트 (server 레벨 아님, location 안)
    location / {
        return 301 https://$host$request_uri;
    }
}

반영 및 검증:

docker exec nginx_mattermost nginx -t          # 문법 확인 (ssl_stapling warn은 무해)
docker exec nginx_mattermost nginx -s reload

# 검증 경로가 http로 실제 서빙되는지 테스트
docker run --rm -v shared-webroot:/w alpine sh -c \
  'mkdir -p /w/.well-known/acme-challenge && echo OK-HELLO > /w/.well-known/acme-challenge/test'
curl -s http://mattermost.example.com/.well-known/acme-challenge/test
# → OK-HELLO 가 나와야 성공 (301 HTML이 나오면 conf 수정이 안 된 것)

# 테스트 파일 정리
docker run --rm -v shared-webroot:/w alpine rm -f /w/.well-known/acme-challenge/test

9-3. 갱신 시뮬레이션 (dry-run)으로 최종 확인

docker run --rm \
  -v /root/docker/certs/etc/letsencrypt:/etc/letsencrypt \
  -v /root/docker/certs/lib/letsencrypt:/var/lib/letsencrypt \
  -v shared-webroot:/usr/share/nginx/html \
  certbot/certbot renew --dry-run --no-random-sleep-on-renew

Congratulations, all simulated renewals succeeded 가 나오면 완료입니다.

"멈춘 것처럼 보이는" 정상 동작: --no-random-sleep-on-renew 없이 실행하면 certbot이 최대 8분(랜덤)까지 일부러 대기한 뒤 진행합니다(Non-interactive renewal: random delay of ... seconds). 이는 전 세계 서버가 동시에 Let's Encrypt를 때리지 않도록 부하를 분산하는 의도된 동작이며, cron 환경에서는 오히려 바람직합니다. 테스트할 때만 --no-random-sleep-on-renew 로 이 대기를 건너뛰어 즉시 결과를 봅니다.

9-4. 갱신 스크립트 생성

cat > /root/docker/scripts/renew-cert.sh <<'EOF'
#!/bin/bash
set -e
CERTS=/root/docker/certs

# 만료 30일 이내일 때만 실제 갱신 (아니면 조용히 넘어감)
docker run --rm \
  -v "$CERTS/etc/letsencrypt:/etc/letsencrypt" \
  -v "$CERTS/lib/letsencrypt:/var/lib/letsencrypt" \
  -v shared-webroot:/usr/share/nginx/html \
  certbot/certbot renew --quiet

# 갱신됐을 수 있으니 nginx가 새 인증서를 다시 읽게 reload (무중단)
docker exec nginx_mattermost nginx -s reload
EOF

chmod +x /root/docker/scripts/renew-cert.sh

cron에서는 랜덤 지연을 그대로 둡니다(부하 분산). 그래서 스크립트의 renew --quiet에는 --no-random-sleep-on-renew넣지 않습니다.

9-5. cron 등록 (하루 2회 — certbot 공식 권장)

crontab -e

아래 한 줄 추가:

0 3,15 * * * /root/docker/scripts/renew-cert.sh >> /var/log/cert-renew.log 2>&1

확인:

crontab -l | grep renew-cert

동작 원리: certbot renew는 만료 30일 이내일 때만 실제 갱신합니다. 매일 두 번 돌아도 그전에는 "not due"로 조용히 넘어가므로 rate limit 걱정이 없습니다. 갱신되면 fullchain.pem 내용만 바뀌고 경로는 그대로여서, 스크립트의 nginx -s reload 한 줄이 새 인증서를 무중단으로 반영합니다.

문제 해결 — dry-run이 계속 멈추면

  1. curl ...acme-challenge/test301이면 → 9-2 nginx conf 수정 안 됨 (리다이렉트 예외 누락)
  2. --no-random-sleep-on-renew 없이 멈춘 것처럼 보이면 → 랜덤 지연(정상). 2~3분 대기하거나 플래그 추가
  3. Requesting a certificate 부근에서 진짜 멈추면 → 컨테이너 outbound(드물게 IPv6) 문제. docker run--network host 추가로 우회

요약 체크리스트

  • Docker / Docker Compose 설치 확인
  • 도메인 DNS A레코드 → 서버 공인 IP (dig = curl ifconfig.me)
  • 방화벽/보안그룹에서 80·443 (+ Calls 쓰면 8443/udp,tcp) 개방
  • .envDOMAIN, MM_SERVICESETTINGS_SITEURL을 실제 도메인으로 설정
  • bash scripts/issue-certificate.sh -d <도메인> -o ${PWD}/certs 로 인증서 발급 (⚠️ -o는 이메일 아님, 절대경로)
  • .envCERT_PATH/KEY_PATH를 발급된 fullchain.pem/privkey.pem 경로로 교체
  • -f docker-compose.yml -f docker-compose.nginx.yml up -d 로 기동, logs -f nginx 확인
  • https://<도메인> 접속 → 자물쇠 확인
  • mmctl --local user list 로 기존 계정 확인
  • mmctl --local user create ... --system-admin 으로 관리자 계정 추가
  • nginx 80블록에 acme-challenge 예외 추가 (webroot 갱신용, 리다이렉트는 location / 안)
  • certbot renew --dry-run --no-random-sleep-on-renew 로 갱신 검증 (succeeded 확인)
  • renew-cert.sh 생성 + cron 하루 2회 등록 (자동 갱신)