flowoodz.
← blog
15분 읽기글 flowoodz

Coolify API deploy: GitHub 자동 배포와 겹쳐 같은 커밋을 두 번 빌드한 기록

Coolify API deploy를 git push 뒤에 습관처럼 불렀더니 GitHub 자동 배포와 겹쳐 같은 커밋을 다시 빌드하고 있었습니다. 이 사이트에서만 12번입니다. 배포 기록의 is_webhook·is_api로 찾은 방법과 push 뒤 확인 순서를 적었습니다.

목차
  1. 배포가 1분 넘게 queued였습니다
  2. 배포 기록에 is_webhook과 is_api가 남아 있었습니다
  3. 겹친 배포가 실제로 한 일
  4. Coolify 배포 API에서 실제로 쓴 것
  5. failed의 이유는 로그 마지막 몇 줄에 있었습니다
  6. push 뒤에는 배포 목록부터 봅니다
  7. 정리

flowoodz.com과 서브도메인 앱들은 서버 한 대의 Coolify에서 돕니다. 도메인과 Cloudflare 구성은 Coolify 사용법 글에 적었습니다. 저장소는 GitHub App으로 연결돼 있어서 main에 push하면 Coolify가 알아서 빌드합니다.

그런데 제 배포 절차 문서에는 이런 줄이 있었습니다.

push만으로 webhook이 자동 배포되지 않는 경우가 있었습니다. API로 명시적으로 트리거하세요.

그래서 저도, 제 대신 배포하는 코딩 에이전트도 push 다음에는 늘 Coolify 배포 API를 한 번 더 불렀습니다. 10월 21일 배포 로그에 queued가 1분 넘게 찍혀 있었고, 25일에 그 이유를 찾다가 문서의 한 줄이 무엇을 하고 있었는지 알았습니다.

결론부터 적습니다. Coolify에 남은 배포 기록에서 webhook이 빠진 커밋은 이 사이트 기준 0건이었습니다. 반대로 API가 webhook과 같은 커밋을 다시 빌드한 경우는 12번이었습니다.

배포가 1분 넘게 queued였습니다#

10월 21일 오후 4시 4분(한국 시간)에 자동 개선 작업이 만든 커밋(fsd: CHANGELOG 갱신)을 push하고 배포 API를 불렀습니다. 응답은 평소와 같았습니다.

{"deployments":[{"message":"Application flowoodz deployment queued.","resource_uuid":"<앱 uuid>","deployment_uuid":"pl2d68cwp92gniqdes1d62i0"}]}

이 deployment_uuid로 20초마다 상태와 커밋을 찍었습니다.

1 queued HEAD
2 queued HEAD
3 queued HEAD
4 queued HEAD
5 in_progress 76a694937f0b0c86c1e789d0f7e3ba0403248833
6 in_progress 76a694937f0b0c86c1e789d0f7e3ba0403248833
7 in_progress 76a694937f0b0c86c1e789d0f7e3ba0403248833
8 in_progress 76a694937f0b0c86c1e789d0f7e3ba0403248833
9 in_progress 76a694937f0b0c86c1e789d0f7e3ba0403248833
10 finished 76a694937f0b0c86c1e789d0f7e3ba0403248833

평소에는 곧바로 in_progress로 넘어갑니다. 그날은 서버가 바쁜가 보다 하고 기다렸습니다. 25일에 그날의 배포 목록을 다시 열어 보니 40초 앞에 배포가 하나 더 있었습니다. 같은 커밋 76a6949였습니다.

생성 (한국 시간) 끝남 누가 걸었나 캐시
16:03:25 16:05:18 webhook 사용
16:04:05 16:07:07 API, force=true 버림
16:45:38 16:47:27 API, force=true 버림

push를 받은 webhook이 먼저 빌드를 시작했고, 제가 부른 API 배포는 그 뒤에 줄을 섰습니다. 제 서버에서는 같은 앱의 배포가 앞의 것이 끝날 때까지 queued로 기다렸습니다.

사이트에는 16시 5분 18초에 이미 새 버전이 올라가 있었습니다. 저는 그걸 모르고 두 번째 빌드가 끝난 16시 7분에 반영을 확인했습니다. 세 번째 줄은 40분 뒤에 같은 커밋을 또 건 것인데, 왜 걸었는지는 기록에 남아 있지 않습니다.

배포 기록에 is_webhook과 is_api가 남아 있었습니다#

한 번만 그런 건지 궁금해서 10월 25일에 Coolify에 남은 배포 기록을 앱별로 전부 받았습니다. GET /api/v1/deployments/applications/<앱 uuid>는 count와 deployments 배열을 돌려주는데, 배포마다 이런 필드가 붙어 있습니다.

{
  "status": "finished",
  "commit": "9df8ac67d8fbaf611b511533ba5ad798b13374e2",
  "is_webhook": true,
  "is_api": false,
  "force_rebuild": false,
  "created_at": "2026-10-22T18:14:30.000000Z",
  "finished_at": "2026-10-22T18:16:26.000000Z"
}

is_webhook은 GitHub push로 생긴 배포, is_api는 API로 건 배포입니다. 커밋별로 묶으면 겹친 배포가 바로 드러납니다. 배포 기록이 있는 앱 8개를 세어 보니 이랬습니다.

앱 배포 webhook API 둘 다 빌드한 커밋 API로만 빌드한 커밋
flowoodz.com 49 37 12 11 0
RAYFRAME 59 49 10 4 1
비공개 앱 1개 88 53 35 33 2
gif-moments 16 15 1 0 1
learncave 22 21 1 0 1
Playground 7 6 1 0 1
Plan Wall 15 15 0 0 0
Reelpool 5 5 0 0 0
합계 261 201 60 48 6

이 사이트만 보면 36개 커밋을 49번 빌드했습니다. API로 건 12번 가운데 11번은 webhook 배포가 생긴 지 4~40초 뒤였습니다. push하고 바로 API를 부르던 습관이 그대로 찍혀 있습니다. 나머지 1번이 위 표의 40분 뒤 배포입니다.

12번 모두 force=true라 캐시 없이 처음부터 빌드했습니다. 생성부터 끝까지 걸린 시간을 더하면 37분입니다. 이 사이트 빌드는 보통 109초(중간값) 걸립니다.

API로만 빌드한 커밋은 전체에서 6개였습니다. 그중 3개는 API로 앱을 막 만든 직후의 첫 배포였습니다. 나머지 3개는 왜 webhook 없이 API만 남았는지 확인하지 못했습니다.

문서의 "webhook이 빠진 경우가 있었다"는 한 줄은 남은 기록으로는 확인되지 않았습니다. 언제 무슨 일이 있었는지 적어 둔 곳도 없습니다.

이 사이트의 배포 기록은 8월 28일부터 남아 있어서, 그 전에 있었던 일이라면 지금은 볼 방법이 없습니다. 이건 미결입니다.

겹친 배포가 실제로 한 일#

사이트가 깨지지는 않았습니다. 같은 커밋을 두 번 올렸을 뿐이니까요. 그래서 한 달 가까이 아무도 몰랐습니다. 손해는 세 가지였습니다.

  1. 반영 확인이 늦어졌습니다. 두 번째 배포를 기다리는 동안 첫 번째가 이미 사이트를 바꿔 놓았습니다. 위 사례에서는 1분 49초를 더 기다렸습니다.
  2. 빌드 자리를 차지했습니다. 이 서버의 Coolify는 동시 빌드가 2개(concurrent_builds: 2)입니다. 10월 17일 저녁 8시 36분부터 2분 사이에 배포 4개가 몰렸는데, 그중 하나가 겹친 배포였습니다.
  3. force=true는 캐시를 버립니다. Docker 레이어 캐시로 금방 끝날 빌드를 처음부터 다시 했습니다.

Coolify 배포 API에서 실제로 쓴 것#

Coolify 4.1.2에서 제가 쓴 호출만 적습니다.

하는 일 호출
배포 걸기 GET /api/v1/deploy?uuid=<앱 uuid>&force=false
앱의 최근 배포 GET /api/v1/deployments/applications/<앱 uuid>?take=3
배포 하나의 상태와 로그 GET /api/v1/deployments/<deployment_uuid>
앱 정보 GET /api/v1/applications/<앱 uuid>

인증은 Authorization: Bearer <토큰> 헤더 하나입니다. 공식 API 토큰 문서에 따르면 토큰을 만들 때 read, read:sensitive, write, deploy, root 중에서 권한을 고르고, 배포만 거는 용도라면 deploy 토큰을 쓰라고 합니다.

다만 배포 로그를 읽는 권한은 read:sensitive에 속합니다. 제 토큰으로는 로그가 읽혔지만, 권한마다 무엇이 막히는지 따로 시험해 보지는 않았습니다.

공식 배포 API 문서의 예시는 POST /api/v1/deploy입니다. 저는 처음부터 GET으로 불렀고 4.1.2에서 문제없이 동작했습니다.

상태는 queued, in_progress, 그다음 finished나 failed로 바뀝니다. 아래는 직접 밟고 나서 안 것들입니다.

  • API로 건 배포는 queued 동안 commit이 HEAD입니다. 빌드가 시작돼야 실제 SHA로 바뀝니다. 그래서 커밋으로 배포를 찾으면 대기 중인 API 배포는 잡히지 않습니다.
  • 앱 정보의 git_commit_sha는 늘 HEAD였습니다. 지금 떠 있는 커밋은 배포 목록의 commit으로 봐야 합니다.
  • 앱 정보의 is_auto_deploy_enabled는 null로 왔습니다. 자동 배포가 도는지는 배포 기록의 is_webhook으로 확인했습니다.
  • 도메인이 없는 앱은 fqdn이 null입니다. 앱 목록에서 이름과 도메인을 이어 붙여 거르다가 TypeError: can only concatenate str (not "NoneType") to str로 스크립트가 죽었습니다.
  • logs는 JSON 배열을 담은 문자열입니다. 한 번 더 파싱해야 줄 단위로 읽힙니다.

failed의 이유는 로그 마지막 몇 줄에 있었습니다#

남은 기록의 실패 4건 가운데 2건을 다시 열어 봤습니다.

gif-moments는 10월 13일과 14일에 연달아 실패했습니다. Dockerfile 빌드 단계에서 bun run check && bun run test && bun run build를 돌리는데, 린트에서 멈췄습니다.

src/App.tsx:142:2 lint/correctness/useExhaustiveDependencies  FIXABLE
  × This hook does not specify its dependency on onFiles.
ERROR: failed to build: failed to solve: process "/bin/sh -c bun run check && bun run test && bun run build" did not complete successfully: exit code: 1
Deployment failed. Removing the new version of your application.

로그 마지막 줄대로 Coolify는 새 버전을 지우고 10월 12일에 성공한 빌드를 그대로 두었습니다.

그래서 사이트가 열린다는 사실만으로는 배포가 성공했는지 알 수 없습니다. 밖에서 지켜보는 Uptime Kuma도 옛 컨테이너가 살아 있으면 초록색입니다. 판정은 배포의 status로 합니다.

Python slim 이미지로 만든 비공개 앱은 첫 배포에서 헬스체크가 실패했습니다.

Healthcheck logs: /bin/sh: 1: curl: not found
/bin/sh: 1: wget: not found
 | Return code: 1
New container is unhealthy.
New container is not healthy, rolling back to the old container.

Coolify 헬스체크는 컨테이너 안에서 curl이나 wget을 실행합니다. 7월에 Next.js를 올릴 때 alpine 이미지에 wget이 없어 똑같이 막혔습니다. 베이스 이미지를 바꾸자 같은 실수를 또 했습니다.

Dockerfile에 apt-get install -y --no-install-recommends curl을 넣은 다음 배포에서 Healthcheck status: "healthy"가 나왔습니다.

push 뒤에는 배포 목록부터 봅니다#

기록을 보고 정리한 순서는 이렇습니다. push한 커밋의 배포가 이미 생겼으면 그걸 따라가고, 30초 안에 안 생기면 그때 API를 부릅니다.

API="https://coolify.example.com/api/v1"
AUTH="Authorization: Bearer $COOLIFY_API_KEY"
APP="<앱 uuid>"
SHA=$(git rev-parse HEAD)
 
# 1. push로 생긴 배포가 있는지 30초 동안 찾는다
DEP=""
for i in 1 2 3 4 5 6; do
  DEP=$(curl -s -H "$AUTH" "$API/deployments/applications/$APP?take=5" \
    | jq -r --arg sha "$SHA" '.deployments[] | select(.commit == $sha) | .deployment_uuid' \
    | head -n 1)
  [ -n "$DEP" ] && break
  sleep 5
done
 
# 2. 없을 때만 API로 건다 (캐시는 그대로 쓴다)
if [ -z "$DEP" ]; then
  DEP=$(curl -s -H "$AUTH" "$API/deploy?uuid=$APP&force=false" \
    | jq -r '.deployments[0].deployment_uuid')
fi
 
# 3. 끝날 때까지 상태를 본다
while :; do
  S=$(curl -s -H "$AUTH" "$API/deployments/$DEP" | jq -r .status)
  echo "$S"
  case "$S" in queued|in_progress) sleep 15 ;; *) break ;; esac
done

1번 단계는 10월 25일에 이 사이트의 현재 커밋으로 돌려 봤습니다. webhook이 건 배포 하나(is_webhook: true)만 나왔습니다. 2번과 3번까지 이어서 돌린 적은 아직 없습니다.

제 배포 절차 문서 몇 곳에도 "push 뒤 API 호출" 줄이 아직 남아 있습니다.

force=true는 캐시가 꼬였다고 의심될 때만 붙이는 게 맞다고 봅니다. 습관으로 붙이면 위에서 센 것처럼 37분이 조용히 사라집니다.

정리#

  • push 뒤에 API를 또 부르면 같은 커밋이 두 번 빌드됩니다. 배포 기록의 is_webhook과 is_api로 확인할 수 있습니다.
  • 반영 확인은 push한 커밋의 배포를 찾아 그 status로 합니다. 앱 정보의 git_commit_sha와 is_auto_deploy_enabled는 판단에 쓰지 않습니다.
  • failed면 logs의 마지막 몇 줄을 읽습니다. 사이트가 열려 있어도 배포는 실패했을 수 있습니다.

한 번 겪은 예외를 규칙으로 적어 두면, 그 예외가 사라진 뒤에도 규칙은 계속 돕니다. 이번에 그 값은 빌드 37분이었고 사이트는 멀쩡했습니다. 운이 좋았다고 생각합니다.

"혹시 몰라서" 넣는 단계가 또 생기면, 그 단계가 실제로 몇 번 필요했는지도 같이 세어 둘 생각입니다.