Ở Bài 06: Triển khai ứng dụng thực tế với Docker, chúng ta đã đưa được một stack App + PostgreSQL + Redis lên server. Nhưng mỗi lần có code mới, bạn vẫn phải tự tay làm một loạt việc: build image, gắn tag, push lên registry, SSH vào server, sửa .env, rồi pull và up.
Làm vài lần thì không sao. Làm mỗi ngày, với nhiều người cùng commit, thì sớm muộn cũng có lúc quên một bước, deploy nhầm tag, hoặc đẩy lên production một bản chưa ai chạy thử.
Bài viết này sẽ tự động hóa toàn bộ quy trình đó với Jenkins: chỉ cần git push, phần còn lại để máy làm.
CI/CD Là Gì Và Vì Sao Cần?
- CI (Continuous Integration): mỗi lần có code mới, hệ thống tự động build và chạy test để phát hiện lỗi sớm, ngay trên nhánh của người viết.
- CD (Continuous Delivery/Deployment): code đã qua test sẽ được tự động đóng gói và triển khai lên server.
So sánh với cách làm ở Bài 06:
| Deploy thủ công | Deploy bằng pipeline | |
|---|---|---|
| Các bước | Nhớ trong đầu hoặc trong một file ghi chú | Viết thành code (Jenkinsfile), commit cùng source |
| Test trước khi deploy | Tùy người, dễ bỏ qua | Bắt buộc, test lỗi thì dừng |
| Tag image | Tự đặt, dễ trùng hoặc nhầm | Tự động theo commit SHA |
| Ai được deploy | Ai có quyền SSH vào server | Chỉ Jenkins, chỉ từ nhánh main |
| Rollback | SSH vào sửa tay | Bấm một nút, nhập tag cũ |
| Lịch sử | Không có | Mỗi lần build có log, thời gian, người trigger |
Pipeline chúng ta sẽ xây dựng:
git push ──▶ GitHub ──webhook──▶ Jenkins
│
┌─────────────────────────┴─────────────────────────┐
│ Checkout ▶ Build image ▶ Test ▶ Push ▶ Deploy │
│ (chỉ nhánh main) │
└─────────────────────────┬─────────────────────────┘
│ ssh
▼
Server: docker compose pull && up -d
Bước 1: Cài Jenkins Bằng Docker
Nên cài Jenkins trên một máy riêng, không phải server production. Jenkins sẽ build image và chạy test, việc này tốn CPU/RAM và không nên tranh tài nguyên với ứng dụng thật.
Image jenkins/jenkins chính thức không có Docker CLI, nên ta build một image mới dựa trên nó. Tạo thư mục ~/jenkins với file Dockerfile:
FROM jenkins/jenkins:lts-jdk21
USER root
# Cài Docker CLI + Compose plugin (chỉ CLI, không cài Docker daemon) và SSH client
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl openssh-client \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
USER jenkins
RUN jenkins-plugin-cli --plugins \
workflow-aggregator git github-branch-source pipeline-graph-view \
credentials-binding ssh-agent timestamper slack
Các plugin được cài sẵn:
| Plugin | Dùng để |
|---|---|
workflow-aggregator |
Bộ plugin Pipeline, đọc và chạy Jenkinsfile |
git, github-branch-source |
Lấy code từ GitHub, tạo Multibranch Pipeline, nhận webhook |
pipeline-graph-view |
Hiển thị các stage dạng sơ đồ, xem log theo từng bước |
credentials-binding |
Đưa mật khẩu/token vào biến môi trường một cách an toàn |
ssh-agent |
Dùng SSH key đã lưu trong Jenkins để SSH vào server |
timestamper |
Thêm thời gian vào từng dòng log |
slack |
Gửi thông báo kết quả build vào Slack |
Tiếp theo là docker-compose.yml:
services:
jenkins:
build: .
ports:
- "8080:8080"
volumes:
- jenkins_home:/var/jenkins_home
- /var/run/docker.sock:/var/run/docker.sock
group_add:
- "${DOCKER_GID}"
restart: unless-stopped
volumes:
jenkins_home:
jenkins_home: named volume chứa toàn bộ cấu hình, job, lịch sử build. Đây là thứ cần backup (Bài 04)./var/run/docker.sock: cho Docker CLI trong container nói chuyện với Docker daemon của máy host. Nhờ vậy Jenkins chạy đượcdocker build,docker compose.group_add: userjenkinstrong container cần thuộc group sở hữu file socket thì mới được dùng nó.
Khởi động:
cd ~/jenkins
echo "DOCKER_GID=$(stat -c %g /var/run/docker.sock)" > .env
docker compose up -d --build
# Lấy mật khẩu admin ban đầu
docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
Mở http://<ip-may-jenkins>:8080, dán mật khẩu vừa lấy, chọn Install suggested plugins rồi tạo tài khoản admin.
Cảnh báo bảo mật: mount
docker.sockvào container đồng nghĩa với việc trao quyền root trên máy host cho Jenkins. Bất kỳ ai chạy được pipeline (hoặc sửa đượcJenkinsfile) đều có thể dùng Docker để đọc, ghi mọi file trên máy đó. Vì vậy:
- Không mở Jenkins trực tiếp ra internet. Đặt sau reverse proxy có HTTPS, hoặc chỉ truy cập qua VPN.
- Tắt đăng ký tài khoản, dùng mật khẩu mạnh, phân quyền chặt.
- Không tự động build Pull Request từ fork của người lạ.
- Không cài Jenkins trên server production.
Với hệ thống lớn hơn, nên tách việc build sang các agent riêng, hoặc dùng các công cụ build không cần Docker daemon (Kaniko, BuildKit rootless).
Bước 2: Các Khái Niệm Chính
| Khái niệm | Ý nghĩa |
|---|---|
| Job | Một “công việc” trong Jenkins. Có nhiều loại: Freestyle, Pipeline, Multibranch Pipeline… |
| Pipeline | Chuỗi các bước từ code đến deploy, mô tả bằng code |
| Jenkinsfile | File chứa pipeline, đặt ở thư mục gốc của repo, được review và commit như code bình thường |
| Declarative Pipeline | Cú pháp pipeline { ... } có cấu trúc rõ ràng, dễ đọc. Bài này dùng cú pháp này |
| Agent | Máy (hoặc container) thực thi pipeline. agent any nghĩa là chạy ở bất kỳ máy nào đang rảnh |
| Stage | Một giai đoạn lớn: Build, Test, Deploy… Hiển thị thành từng ô trên giao diện |
| Step | Một lệnh cụ thể trong stage: sh, echo, checkout… |
| Credentials | Kho lưu mật khẩu, token, SSH key. Pipeline chỉ tham chiếu bằng ID, không bao giờ chứa giá trị thật |
Bước 3: Chuẩn Bị Credentials Và Server
Credentials trong Jenkins
Vào Manage Jenkins → Credentials → System → Global credentials → Add Credentials và tạo 2 mục:
registry, loại Username with password: username là tài khoản GitHub, password là Personal Access Token có quyềnwrite:packages(dùng để push image lên GHCR).deploy-ssh, loại SSH Username with private key: username làdeploy, private key là key dành riêng cho Jenkins.
Tạo SSH key riêng cho Jenkins (đừng dùng lại key cá nhân của bạn):
ssh-keygen -t ed25519 -C "jenkins-deploy" -f jenkins_deploy -N ""
# jenkins_deploy → dán vào credential "deploy-ssh"
# jenkins_deploy.pub → thêm vào ~/.ssh/authorized_keys của user deploy trên server
Trên server production
Chuẩn bị giống Bước 5 của Bài 06, với một user riêng tên deploy:
sudo adduser deploy
sudo usermod -aG docker deploy # để user deploy chạy được lệnh docker
sudo su - deploy
mkdir -p ~/myapp ~/.ssh && cd ~/myapp
# copy docker-compose.yml và .env của Bài 06 vào đây
# dán nội dung jenkins_deploy.pub vào ~/.ssh/authorized_keys
docker login ghcr.io -u <username> # chỉ cần nếu image là private
Thành viên group
dockercũng tương đương root. Hãy coi private key củadeploynhư mật khẩu root của server.
Bước 4: Viết Jenkinsfile
Trong repo myapp của Bài 06, thêm 2 file vào thư mục gốc.
docker-compose.ci.yml, dùng riêng cho bước test:
services:
app:
ports: !reset []
File này gỡ bỏ phần ports: "3000:3000" của service app khi chạy test. Nhờ vậy nhiều build có thể chạy cùng lúc mà không tranh nhau port 3000 trên máy Jenkins. Cú pháp !reset cần Docker Compose 2.24 trở lên.
Jenkinsfile:
pipeline {
agent any
parameters {
string(name: 'ROLLBACK_TAG', defaultValue: '', description: 'Tag cũ cần deploy lại (ví dụ a1b2c3d). Bỏ trống để build bình thường.')
}
environment {
REGISTRY = 'ghcr.io'
IMAGE = 'ghcr.io/<username>/myapp'
DEPLOY_HOST = 'deploy@<server-ip>'
DEPLOY_DIR = '/home/deploy/myapp'
}
options {
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '20'))
timeout(time: 30, unit: 'MINUTES')
timestamps()
}
stages {
stage('Checkout') {
steps {
checkout scm
script {
env.IMAGE_TAG = params.ROLLBACK_TAG ?: sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
env.CI_PROJECT = env.BUILD_TAG.toLowerCase().replaceAll(/[^a-z0-9_-]/, '-')
}
echo "Image: ${IMAGE}:${IMAGE_TAG}"
}
}
stage('Build image') {
when { expression { !params.ROLLBACK_TAG } }
steps {
sh 'docker build -t "$IMAGE:$IMAGE_TAG" .'
}
}
stage('Test') {
when { expression { !params.ROLLBACK_TAG } }
environment {
APP_IMAGE = "${env.IMAGE}:${env.IMAGE_TAG}"
COMPOSE_PROJECT_NAME = "${env.CI_PROJECT}"
COMPOSE_FILE = 'docker-compose.yml:docker-compose.ci.yml'
POSTGRES_USER = 'ci'
POSTGRES_PASSWORD = 'ci'
POSTGRES_DB = 'ci'
}
steps {
sh 'docker compose up -d --no-build --wait'
sh 'docker compose exec -T app wget -qO- http://localhost:3000/'
}
post {
always {
sh 'docker compose down -v'
}
}
}
stage('Push') {
when {
branch 'main'
expression { !params.ROLLBACK_TAG }
}
steps {
withCredentials([usernamePassword(credentialsId: 'registry', usernameVariable: 'REG_USER', passwordVariable: 'REG_TOKEN')]) {
sh '''
echo "$REG_TOKEN" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
docker push "$IMAGE:$IMAGE_TAG"
docker logout "$REGISTRY"
'''
}
}
}
stage('Deploy') {
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh']) {
sh '''
ssh -o StrictHostKeyChecking=accept-new "$DEPLOY_HOST" "
set -e
cd $DEPLOY_DIR
sed -i '/^#* *APP_IMAGE=/d' .env
echo 'APP_IMAGE=$IMAGE:$IMAGE_TAG' >> .env
docker compose pull app
docker compose up -d --no-build --wait app
"
'''
}
}
}
}
post {
success {
slackSend(color: 'good', message: "✅ ${env.JOB_NAME} #${env.BUILD_NUMBER} thành công, image `${env.IMAGE_TAG}` (<${env.BUILD_URL}|Xem build>)")
}
failure {
slackSend(color: 'danger', message: "❌ ${env.JOB_NAME} #${env.BUILD_NUMBER} thất bại (<${env.BUILD_URL}console|Xem log>)")
}
always {
sh 'docker image rm "$IMAGE:$IMAGE_TAG" || true'
}
}
}
Nhớ thay <username> và <server-ip> bằng giá trị thật. Đi qua từng phần:
environment và options
- Các giá trị không bí mật (tên image, địa chỉ server) để thẳng trong
environment. Các giá trị bí mật không bao giờ xuất hiện trong file này. disableConcurrentBuilds(): trên cùng một nhánh, build sau phải chờ build trước xong. Tránh cảnh hai lần deploy chạy chồng lên nhau.timeout: build bị treo quá 30 phút sẽ tự hủy, không chiếm executor mãi mãi.
Stage Checkout
IMAGE_TAGlà commit SHA rút gọn (ví dụ8f381aa). Nhìn tag là biết ngay image được build từ commit nào, đúng lời khuyên “đừng chỉ dùnglatest” ở Bài 06.CI_PROJECTlà tên project Compose riêng cho từng build, tạo từBUILD_TAG(ví dụjenkins-myapp-feature-2flogin-3). Tên nhánh nhưfeature/loginchứa ký tự/không hợp lệ, nên ta thay chúng bằng dấu-.
Stage Build image: build đúng Dockerfile của Bài 06. Lần build sau nhanh hơn nhiều nhờ cache layer.
Stage Test: đây là một smoke test, chạy cả stack thật bằng chính image vừa build:
--waitchờ đến khi mọi container đềuhealthy. NếuHEALTHCHECKtrong Dockerfile báo lỗi, lệnh này thất bại và pipeline dừng ngay, không có gì được push hay deploy.- Sau đó gọi thử endpoint
/từ bên trong containerappđể chắc chắn ứng dụng đọc/ghi được PostgreSQL và Redis. post { always { ... down -v } }: dù test pass hay fail, stack test và volume của nó đều được dọn sạch.- Database test dùng mật khẩu
cicố định vì nó chỉ sống vài chục giây và không mở port ra ngoài.
Nếu dự án có unit test (
npm test), hãy chạy chúng trước smoke test, ví dụ thêm một stagedocker run --rm "$IMAGE:$IMAGE_TAG" npm test(với điều kiện image có devDependencies), hoặc thêm một stagetestriêng trong Dockerfile.
Stage Push
when { branch 'main' }: chỉ nhánhmainmới được push. Các nhánh feature chỉ build và test.withCredentialsđưa username/token vào biến môi trường chỉ trong khối này. Jenkins tự che giá trị thành****nếu chúng lỡ xuất hiện trong log.- Lệnh
shdùng nháy đơn'''...''', để shell (không phải Groovy) đọc biến$REG_TOKEN. Nếu dùng nháy kép, Groovy sẽ chèn token thẳng vào câu lệnh và Jenkins sẽ cảnh báo rò rỉ bí mật.
Stage Deploy
sshagentnạp SSH key từ credentialdeploy-sshvào mộtssh-agenttạm thời, chỉ tồn tại trong khối lệnh này. Key không nằm trong workspace hay trong log.- Trên server, lệnh
sedxóa dòngAPP_IMAGEcũ (kể cả dòng đang bị comment như trong file.envmẫu của Bài 06), sau đó ghi tag mới vào. Ghi vào.envthay vì chỉ truyền biến tạm thời giúp server nhớ phiên bản đang chạy: khi server reboot hay ai đó chạydocker compose up -d, đúng image đó sẽ được dùng. docker compose up -d --no-build --wait appchỉ tạo lại containerapp, database và Redis vẫn chạy bình thường.--waitkhiến stage báo lỗi nếu bản mới khônghealthy.- Các biến
$IMAGE,$IMAGE_TAG,$DEPLOY_DIRnằm trong chuỗi nháy kép nên được thay giá trị ngay trên máy Jenkins trước khi gửi sang server.
Khối post cuối cùng: gửi kết quả vào Slack, và xóa image trên máy Jenkins để ổ cứng không đầy dần theo số lần build.
Bước 5: Tạo Multibranch Pipeline
Vào New Item, đặt tên myapp, chọn Multibranch Pipeline:
- Branch Sources → Add source → GitHub: chọn credential để đọc repo (một Personal Access Token có quyền
reponếu repo là private), nhập URL của repo. - Build Configuration: giữ mặc định by Jenkinsfile, đường dẫn
Jenkinsfile. - Lưu lại. Jenkins sẽ quét repo và tạo một job con cho mỗi nhánh có Jenkinsfile.

Multibranch Pipeline tự tạo job cho từng nhánh: main được deploy, nhánh feature/broken bị chặn ở bước test
Vì sao nên dùng Multibranch thay cho Pipeline thường?
- Mỗi nhánh, mỗi Pull Request đều được build và test tự động. Lỗi bị phát hiện trước khi merge vào
main. - Điều kiện
when { branch 'main' }chỉ hoạt động trong Multibranch Pipeline, vì chỉ ở đây Jenkins mới biết build đang chạy cho nhánh nào.
Mô hình này khớp với Gitflow cho dự án thực tế: nhánh feature chỉ chạy CI, còn khi merge vào main thì CD mới chạy. Nếu có môi trường staging, bạn có thể thêm một stage Deploy staging với when { branch 'develop' }, trỏ tới một server khác.
Bước 6: Trigger Tự Động Bằng GitHub Webhook
Để Jenkins build ngay khi có git push, vào repo trên GitHub: Settings → Webhooks → Add webhook:
- Payload URL:
https://jenkins.example.com/github-webhook/(bắt buộc có dấu/ở cuối) - Content type:
application/json - Events: chọn Pushes và Pull requests
GitHub phải gọi được tới Jenkins qua internet. Nếu Jenkins nằm trong mạng nội bộ hoặc sau VPN (như khuyến nghị ở Bước 1), hãy dùng cách thay thế: trong cấu hình job, mục Scan Multibranch Pipeline Triggers, bật Periodically if not otherwise run với chu kỳ 2–5 phút. Jenkins sẽ tự quét repo, chậm hơn webhook một chút nhưng không cần mở cổng ra ngoài.
Kết quả sau khi merge một thay đổi vào main:

Stage Deploy: chỉ container app được tạo lại, db và redis vẫn chạy
Còn khi code lỗi, healthcheck thất bại và pipeline dừng ở bước Test, Push và Deploy đều bị bỏ qua:

Smoke test thất bại: stack test vẫn được dọn sạch, không có gì lên production
Bước 7: Thông Báo Kết Quả Vào Slack
Pipeline đã có sẵn slackSend trong khối post. Để nó gửi được tin nhắn:
- Trên api.slack.com/apps, tạo một Slack app, cấp quyền
chat:writerồi cài vào workspace. Slack sẽ cấp một Bot User OAuth Token (bắt đầu bằngxoxb-). Mời app vào channel nhận thông báo, ví dụ#deploy. - Trong Jenkins, tạo credential loại Secret text chứa token đó.
- Vào Manage Jenkins → System → Slack: điền Workspace, chọn credential vừa tạo, tick Custom slack app bot user, điền Default channel, rồi bấm Test Connection.
Nếu chưa cấu hình, slackSend chỉ ghi một dòng lỗi vào log và không làm build thất bại, nên bạn có thể giữ nguyên Jenkinsfile và cấu hình sau.
Nếu đã làm theo bài Hướng dẫn tích hợp Slack và GitHub, bạn có thể cho cả hai đổ về cùng một channel: GitHub báo có commit/PR mới, Jenkins báo build và deploy thành công hay thất bại. Cả team nhìn một chỗ là biết code đang ở đâu.
Bước 8: Rollback Khi Bản Mới Gặp Sự Cố
Vì mỗi lần build main đều push một image có tag riêng lên registry, việc rollback chỉ là deploy lại một tag cũ. Không cần build lại gì cả.
- Tìm tag cần quay về: mở một build cũ đã chạy tốt, dòng
Image: ghcr.io/<username>/myapp:8f381aaở stage Checkout cho biết tag. Bạn cũng có thể xem danh sách tag trên trang Packages của GitHub. - Vào job
myapp → main, chọn Build with Parameters, điềnROLLBACK_TAG=8f381aa, bấm Build. - Pipeline bỏ qua Build, Test, Push và chạy thẳng stage Deploy với tag đó.
Lưu ý:
- Tham số
ROLLBACK_TAGchỉ xuất hiện sau lần build đầu tiên của nhánh, vì Jenkins cần chạy Jenkinsfile một lần mới biết pipeline có tham số gì. - Rollback chỉ là biện pháp chữa cháy. Lần push tiếp theo vào
mainsẽ lại deploy code mới nhất. Hãygit revertcommit lỗi để sửa tận gốc. - Nếu bản lỗi đã chạy migration làm thay đổi cấu trúc database, chỉ quay lại image cũ là chưa đủ. Hãy thiết kế migration theo hướng tương thích ngược, và backup database trước mỗi lần deploy lớn (xem phần backup ở Bài 06).
Tổng Kết
Sau bài này, quy trình deploy của chúng ta trở thành:
- Developer chỉ cần
git push. Mọi nhánh đều được build và chạy smoke test tự động. - Chỉ code đã qua test trên nhánh
mainmới lên production, với image gắn tag theo commit SHA. - Bí mật nằm trong Jenkins Credentials, không nằm trong code hay trong log.
- Rollback bằng một nút bấm, kết quả mỗi lần build được báo về Slack.
Toàn bộ pipeline trong bài đã được chạy thử trên Jenkins 2.580 và Docker Compose v5 với ứng dụng mẫu của Bài 06: deploy nhánh main, chặn nhánh có healthcheck lỗi, chạy song song hai nhánh và rollback về tag cũ.
Mô hình một server + Docker Compose này phù hợp với phần lớn dự án vừa và nhỏ. Khi ứng dụng cần chạy trên nhiều server, tự co giãn theo tải và deploy không gián đoạn, đó là lúc tìm hiểu đến Kubernetes, chủ đề của một bài viết sắp tới.