Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,19 @@ jobs:

- name: Run rollback integration test
run: bash tests/rollback-integration.sh

node-tests:
# Behavioral unit/integration tests for the template's own scripts and
# example app: scripts/bump-version.js validation + app/server.js
# /health and 404 behavior. Runs the real code via node:test.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 22

- name: Run JS tests
run: npm test
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ Dockerfile → Example Node.js (swap for your language, see docs/DOCKERFI
docker-compose.yml → Local dev + VPS deployment
.env.example → Environment variables template
VERSION → Single source of truth for version (1.0.0)
scripts/bump-version.js → Version bumping (patch/minor/major)
scripts/bump-version.js → Version bumping (patch/minor/major); validates VERSION, fails on malformed
scripts/deploy-with-rollback.sh → Health-checked deploy with auto rollback (shared by cd.yml + ci.yml)
tests/ → node:test suites (bump-version, server /health) + rollback-integration.sh
package.json → Root test runner (`npm test` → node --test)
docs/ → Setup guides (VPS, GHCR, HTTPS, Dockerfile examples)
```

## CI/CD Pipeline

- **ci.yml**: Runs on push/PR to main. Hadolint lint + docker-compose validate + Docker build test + Trivy CVE scan (CRITICAL/HIGH). No secrets needed.
- **ci.yml**: Runs on push/PR to main. Hadolint lint + docker-compose validate + Docker build test + Trivy CVE scan (CRITICAL) + rollback integration test + `node:test` JS suites (`npm test`). No secrets needed.
- **cd.yml**: Manual trigger OR tag push (v*). Builds image (Buildx + GHA cache) → pushes to GHCR → deploys to VPS via SSH → cleans old images → creates GitHub Release. Concurrency controlled (no parallel deploys).
- **setup.yml**: First push only. Auto-creates GitHub Issue with setup checklist.

Expand Down Expand Up @@ -48,6 +51,8 @@ docs/ → Setup guides (VPS, GHCR, HTTPS, Dockerfile examples)
- **Why**: 같은 버전을 두 번 배포하면 GHCR 태그 충돌 + GitHub Release 중복 생성. 이 guard가 없으면 CI 통과해도 CD에서 조용히 깨짐.
- Health check pattern in Dockerfile and docker-compose.yml
- **Why**: `docker compose up -d --wait`가 health check 통과를 기다림. health check 없으면 컨테이너 시작 = 배포 성공으로 판단해서 깨진 앱이 배포될 수 있음.
- `/health` must stay able to FAIL (`app/server.js` readiness checks → 503)
- **Why**: 롤백은 unhealthy 컨테이너 감지에 의존. `/health`를 항상 200으로 고정하면 깨진 배포도 정상으로 보여 롤백이 무력화됨. 의존성 프로브는 `createApp({ readinessChecks })`로 연결.
- Concurrency control in cd.yml
- **Why**: 동시에 두 배포가 실행되면 SSH에서 race condition 발생. `cancel-in-progress: false`로 순서대로 실행.

Expand Down
57 changes: 52 additions & 5 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ docker compose up
├── docker-compose.yml # 로컬 개발용
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Dockerfile 린트, compose 검증, 빌드 테스트
│ │ ├── ci.yml # 린트, compose 검증, 빌드 테스트, JS 테스트
│ │ ├── cd.yml # 빌드 → GHCR 푸시 → VPS SSH 배포
│ │ └── setup.yml # 첫 사용 시 셋업 체크리스트 자동 생성
│ └── PULL_REQUEST_TEMPLATE.md
Expand All @@ -81,22 +81,59 @@ docker compose up
│ ├── HTTPS_SETUP.md # Caddy 리버스 프록시 + 자동 HTTPS
│ └── VPS_DEPLOY.md # VPS SSH 배포 가이드
├── scripts/
│ └── bump-version.js # 버전 범프
│ ├── bump-version.js # 버전 범프 (VERSION 검증)
│ └── deploy-with-rollback.sh # 헬스체크 배포 + 자동 롤백
├── tests/ # node:test 스위트 + 롤백 통합 테스트
├── package.json # `npm test` 러너
└── VERSION # 현재 버전
```

## 기능

- **언어 무관** — Dockerfile만 바꾸면 Node, Python, Go, Rust, Java, 정적 사이트 모두 가능
- **CI 파이프라인** — Dockerfile 린트 (hadolint), docker-compose 검증, 빌드 테스트
- **CI 파이프라인** — Dockerfile 린트 (hadolint), docker-compose 검증, 빌드 테스트, 그리고 매 푸시마다 `node:test` 스위트 (버전 범프 + `/health`) 실행
- **CD 파이프라인** — 빌드 → GHCR 푸시 → docker compose 헬스체크 기반 VPS 배포 + GitHub Release 자동 생성
- **실제 헬스체크** — `/health`가 준비 상태(readiness)를 반영하며 `503`을 반환할 수 있습니다. 배포 실패 시 실제로 롤백되도록 의존성 프로브(DB, 캐시 등)를 직접 연결하십시오
- **Dockerfile 예시** — Node, Python, Go, Rust, Java용 멀티스테이지 빌드 docs 제공
- **버전 관리** — `node scripts/bump-version.js patch/minor/major`
- **버전 관리** — `node scripts/bump-version.js patch/minor/major` (`VERSION`을 검증하며, 파일이 손상된 경우 쓰레기 값을 쓰지 않고 명확히 실패합니다)
- **로컬 개발** — `docker compose up`으로 볼륨 마운트 + 라이브 리로드
- **HTTPS 가이드** — Caddy 리버스 프록시 + 자동 TLS
- **배포 가이드** — GHCR, VPS 설정 단계별 문서
- **템플릿 셋업** — 첫 사용 시 체크리스트 이슈 자동 생성

## 헬스체크 (`/health`)

배포 파이프라인은 새 컨테이너가 헬스체크에 실패하면 롤백합니다
(`docker compose up -d --wait`). 이 안전장치는 `/health`가 실제로 실패를
보고할 수 있을 때만 동작합니다. 항상 `200`을 반환하는 `/health`는 모든
배포를 정상으로 보이게 만들어 롤백을 조용히 무력화합니다.

- **현재 구현됨** — `app/server.js`는 비동기 readiness 체크 목록을 기반으로
`/health`를 제공합니다. 모든 체크 통과 시 `200 {"status":"ok"}`, 하나라도
falsy를 반환하거나 예외를 던지면 `503 {"status":"unavailable"}`을
반환합니다. 알 수 없는 경로는 `404`를 반환합니다(예시 서버는 모든 경로를
받는 catch-all이 아닙니다). 기본 체크는 HTTP 리스너 바인딩만 확인합니다.
- **설계 의도** — fail-closed 원칙: 의존성 장애는 컨테이너를 unhealthy 상태로
드러내어 오케스트레이터가 트래픽 라우팅을 멈추고 CD 롤백이 작동하도록 해야
하며, 정상 체크 뒤에서 고장난 앱을 계속 서빙해서는 안 됩니다.
- **실제 체크는 직접 연결하셔야 합니다.** 예시 앱을 교체하고, 앱이 실제로
필요로 하는 의존성에 대한 프로브를 등록하십시오:

```js
const { createApp } = require('./server.js');
const { server } = createApp({
readinessChecks: [
async () => { await db.query('SELECT 1'); return true; },
async () => (await redis.ping()) === 'PONG',
],
});
server.listen(process.env.PORT || 3000);
```

- **비목표(Non-goals)** — 이것은 메트릭/liveness 프레임워크가 아닙니다. 롤백
로직이 의존하는 최소한의 readiness 계약일 뿐이며, 더 필요하면 사용하시는
스택의 헬스 라이브러리로 교체하십시오.

## CI/CD

### CI (PR + main 푸시마다)
Expand Down Expand Up @@ -153,12 +190,22 @@ docker compose up
# Dockerfile 변경 후 재빌드
docker compose up --build

# 버전 범프
# 버전 범프 (VERSION이 손상되면 1.2.NaN을 쓰지 않고 명확히 실패합니다)
node scripts/bump-version.js patch # 1.0.0 → 1.0.1
node scripts/bump-version.js minor # 1.0.0 → 1.1.0
node scripts/bump-version.js major # 1.0.0 → 2.0.0
```

### 테스트

```bash
# Node 테스트: 버전 범프 검증 + /health (200) 및 알 수 없는 경로 (404)
npm test

# 롤백 통합 테스트 (Docker 필요, CI에서도 실행됨)
bash tests/rollback-integration.sh
```

## 언어 변경

1. `app/`을 내 앱 코드로 교체
Expand Down
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ docker compose up
├── docker-compose.yml # Local development
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Dockerfile lint, compose validate, build test
│ │ ├── ci.yml # Lint, compose validate, build test, JS tests
│ │ ├── cd.yml # Build → GHCR push → VPS deploy via SSH
│ │ └── setup.yml # Auto setup checklist on first use
│ └── PULL_REQUEST_TEMPLATE.md
Expand All @@ -81,22 +81,59 @@ docker compose up
│ ├── HTTPS_SETUP.md # HTTPS with Caddy reverse proxy
│ └── VPS_DEPLOY.md # VPS SSH deployment guide
├── scripts/
│ └── bump-version.js # Version bump utility
│ ├── bump-version.js # Version bump utility (validates VERSION)
│ └── deploy-with-rollback.sh # Health-checked deploy + auto rollback
├── tests/ # node:test suites + rollback integration test
├── package.json # `npm test` runner
└── VERSION # Current version
```

## Features

- **Language agnostic** — Swap the Dockerfile for any language (Node, Python, Go, Rust, Java, static)
- **CI Pipeline** — Dockerfile lint (hadolint), docker-compose validation, build verification, Trivy CVE scan on every push
- **CI Pipeline** — Dockerfile lint (hadolint), docker-compose validation, build verification, Trivy CVE scan, plus `node:test` suites (version-bump + `/health`) on every push
- **CD Pipeline** — Build → push to GHCR → health-checked deploy to VPS via docker compose + auto GitHub Release
- **Real health checks** — `/health` reflects a readiness signal and can return `503`; wire your own dependency probes (DB, cache, …) so failed deploys actually roll back
- **Dockerfile examples** — Multi-stage builds for Node, Python, Go, Rust, Java in docs
- **Version management** — `node scripts/bump-version.js patch/minor/major`
- **Version management** — `node scripts/bump-version.js patch/minor/major` (validates `VERSION`, fails loudly on a malformed file instead of writing garbage)
- **Local dev** — `docker compose up` with volume mounts for live reload
- **HTTPS guide** — Caddy reverse proxy with automatic TLS
- **Deploy guides** — Step-by-step docs for GHCR and VPS setup
- **Template setup** — Auto-creates setup checklist issue on first use

## Health checks (`/health`)

The deploy pipeline rolls back when the new container fails its health check
(`docker compose up -d --wait`). That safety net only works if `/health` can
actually report failure — a `/health` that always returns `200` makes every
deploy look healthy and silently disables rollback.

- **Currently implemented** — `app/server.js` exposes `/health` backed by a
list of async readiness checks. All checks passing → `200 {"status":"ok"}`.
Any check returning falsy or throwing → `503 {"status":"unavailable"}`.
Unknown paths return `404` (the example server is not a catch-all). The
default check only confirms the HTTP listener is bound.
- **Design intent** — fail-closed: a dependency outage should surface as an
unhealthy container so the orchestrator stops routing traffic and the CD
rollback triggers, rather than serving a broken app behind a green check.
- **You must wire real checks.** Replace the example app and register probes
for the dependencies your app actually needs:

```js
const { createApp } = require('./server.js');
const { server } = createApp({
readinessChecks: [
async () => { await db.query('SELECT 1'); return true; },
async () => (await redis.ping()) === 'PONG',
],
});
server.listen(process.env.PORT || 3000);
```

- **Non-goals** — this is not a metrics/liveness framework. It is the minimal
readiness contract the rollback logic depends on; swap in your stack's
health library if you need more.

## CI/CD

### CI (every PR + push to main)
Expand Down Expand Up @@ -154,12 +191,22 @@ docker compose up
# Rebuild after Dockerfile changes
docker compose up --build

# Bump version
# Bump version (fails loudly if VERSION is malformed — never writes 1.2.NaN)
node scripts/bump-version.js patch # 1.0.0 → 1.0.1
node scripts/bump-version.js minor # 1.0.0 → 1.1.0
node scripts/bump-version.js major # 1.0.0 → 2.0.0
```

### Tests

```bash
# Node tests: version-bump validation + /health (200) and unknown path (404)
npm test

# Rollback integration test (needs Docker; also run in CI)
bash tests/rollback-integration.sh
```

## Switching Languages

1. Replace `app/` with your application code
Expand Down
85 changes: 74 additions & 11 deletions app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,79 @@ const http = require('http');

const port = process.env.PORT || 3000;

const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
// --- Readiness checks -------------------------------------------------------
//
// /health is only useful if it can actually report FAILURE — a health check
// that always returns 200 tells your load balancer / orchestrator nothing and
// defeats the rollback logic in scripts/deploy-with-rollback.sh (an unhealthy
// container would look healthy and never roll back).
//
// `readinessChecks` is a list of async functions. Each must resolve truthy
// when its dependency is reachable and reject / resolve falsy otherwise. The
// process records `started` once `listen` fires, which is a real (if minimal)
// readiness signal: before the listener is up, /health reports 503.
//
// TODO(you): wire your real dependencies here. Examples:
// readinessChecks.push(async () => { await db.query('SELECT 1'); return true; });
// readinessChecks.push(async () => (await redis.ping()) === 'PONG');
// A check that throws or returns falsy flips /health to 503 so deploys roll
// back and orchestrators stop routing traffic.
function createApp(options = {}) {
const state = { started: false };
const readinessChecks = options.readinessChecks || [
// Default real signal: the HTTP listener must be bound. This is replaced
// /augmented by callers with real dependency probes.
async () => state.started,
];

async function isReady() {
const results = await Promise.allSettled(
readinessChecks.map((check) => Promise.resolve().then(check))
);
return results.every(
(r) => r.status === 'fulfilled' && Boolean(r.value)
);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', message: 'Hello from Docker!' }));
});

server.listen(port, () => {
console.log(`Server running on port ${port}`);
});
const server = http.createServer((req, res) => {
if (req.url === '/health') {
isReady()
.then((ready) => {
const code = ready ? 200 : 503;
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: ready ? 'ok' : 'unavailable' }));
})
.catch(() => {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'unavailable' }));
});
return;
}

if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', message: 'Hello from Docker!' }));
return;
}

// Unknown path: a real server must not answer 200 for everything.
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'not_found' }));
});

// Mark ready only once the listener is actually bound.
server.on('listening', () => {
state.started = true;
});

return { server, state, isReady, readinessChecks };
}

if (require.main === module) {
const { server } = createApp();
server.listen(port, () => {
console.log(`Server running on port ${port}`);
});
}

module.exports = { createApp };
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "docker-deploy-starter",
"version": "0.0.0",
"private": true,
"description": "Test harness for the docker-deploy-starter template (scripts + app).",
"scripts": {
"test": "node --test \"tests/**/*.test.js\""
}
}
Loading
Loading