Coolify API deploy: GitHub 자동 배포와 겹쳐 같은 커밋을 두 번 빌드한 기록
Coolify API deploy를 git push 뒤에 습관처럼 불렀더니 GitHub 자동 배포와 겹쳐 같은 커밋을 다시 빌드하고 있었습니다. 이 사이트에서만 12번입니다. 배포 기록의 is_webhook·is_api로 찾은 방법과 push 뒤 확인 순서를 적었습니다.
- 셀프호스팅
- Docker
- 트러블슈팅
목차
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분 49초를 더 기다렸습니다.
- 빌드 자리를 차지했습니다. 이 서버의 Coolify는 동시 빌드가 2개(
concurrent_builds: 2)입니다. 10월 17일 저녁 8시 36분부터 2분 사이에 배포 4개가 몰렸는데, 그중 하나가 겹친 배포였습니다. 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
done1번 단계는 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분이었고 사이트는 멀쩡했습니다. 운이 좋았다고 생각합니다.
"혹시 몰라서" 넣는 단계가 또 생기면, 그 단계가 실제로 몇 번 필요했는지도 같이 세어 둘 생각입니다.