Tự động triển khai ứng dụng React.js (Next.js) + Node.js + MongoDB với Docker + GitHub Actions
- Published on

#Tự động triển khai ứng dụng React.js (Next.js) + Node.js + MongoDB với Docker + GitHub Actions
*Vấn đề: Mình có một vài project (sanpickball.xyz, lingq.com) tự triển khai trên server cá nhân. Do là hàng free, nên server này chỉ tồn tại trong một khoảng thời gian ngắn (một vài tháng). Mỗi khi mà server hiện tại không dùng được nữa, phải đổi sang server mới thì mình sẽ phải thiết lập lại server và deploy các project từ đầu (mình có một bài hướng dẫn thiết lập và deploy project trên server thủ công tại đây), mất khá nhiều thời gian. Vì vậy mình đã tìm hiểu để dùng Docker và Github Actions để tự động hóa tối đa việc deploy các project trên server. Mình viết guide này nhằm mục đích ghi nhớ cũng như nếu có bạn nào có cùng mục tiêu thì có thể tham khảo.*
Mục tiêu: Server tự động deploy khi push code lên github.
I. Cấu trúc project
|── san-pickleball
|── pickleball-client/
|── pickleball-server/
Để tiện theo dõi, thì trên đây là cấu trúc project của mình khi đã hoàn thành nhưng chưa bao gồm deploy code. Project của mình sẽ bao gồm 2 phần là pickleball-client chứa code client (Next.js) và pickleball-server chứa code server (Node.js). II. Quy trình deploy
- Workflow của quá trình deploy khá đơn giản: Push code => Dùng Github Actions build docker image => Đẩy image lên Docker Hub => Dùng Github Actions kết nối với server => Copy file docker-compose.yml vào server => Pull docker image => Build docker container => Project chạy. III. Docker
- Dockerfile
- Trước tiên chúng ta sẽ cần tạo Dockerfile để Github Actions có thể build và push Docker Image lên Docker Hub.
- Thêm 2 file Dockerfile vào thư mục /pickleball-client/ và /pickleball-server/.
|── san-pickleball
|── pickleball-client/
|── dockerfile
|── pickleball-server/
|── dockerfile
- Đây là nội dung của Dockerfile trong pickleball-client, để đơn giản mình sẽ sử dụng Dockerfile cho Next.js chưa được tối ưu (Nếu bạn quan tâm đến việc tối ưu Docker Image, có thể xem thêm trong bài [Optimize Docker Image]):
# Use Node.js image
FROM node:18
# Set working directory
WORKDIR /app
# Copy package.json and lock file first
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install
# Copy all project files
COPY . .
# Build Next.js app
RUN yarn build
# Expose Next.js default port
EXPOSE 8080
# Start Next.js app
CMD ["yarn", "start"]
- Tương tự, đây là nội dung của Dockerfile trong pickleball-client (cũng chưa được tối ưu):
# Use Node.js image
FROM node:18
# Set working directory
WORKDIR /app
# Copy package.json and lock file first
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install
# Copy all project files
COPY . .
# Expose the port your app listens on (example: 2704)
EXPOSE 2704
# Start the Node.js app
CMD ["yarn", "start"]
- Hai dockerfiles ở trên chỉ đơn giản là build project và expose ở port 8080 cho client và 2704 cho server trong container.
- Bây giờ, bạn có thể sử dụng terminal, cd vào thư mục /pickleball-client/ và chạy
docker build -t pickleball-client .
để build image từ dockerfile trong pickleball-client, bạn có thể kiểm tra xem docker image đã được build chưa bằng commanddocker images
. Sau đó chạydocker run -p 8080:8080 pickleball-client
để chạy container từpickleball-client
image và chỉ định cổng để truy cập từ máy tính của bạn. Giờ bạn có thể truy cập vào app thông qua cổng 8080 trên local: localhost:8080. - Đối image pickleball-server, do có phụ thuộc vào mongodb nên chưa thể chạy ngay, nhưng về bản chất thì cũng đã gần như hoàn thành.
- Sau khi Docker Image từ Dockerfile, chúng ta có thể đẩy Docker Image lên Docker Hub (cần login trước bằng
docker login
) thông qua command tagdocker tag pickleball-client mydockerhubusername/pickleball-client:latest
và sau đó là command pushdocker push mydockerhubusername/pickleball-client:latest
. Như vậy lúc này trên Docker Hub của chúng ta đã có 2 repo là mydockerhubusername/pickleball-client và mydockerhubusername/pickleball-server.
- Docker compose
- Hiện tại chúng ta có thể sử dụng command để thêm mongo container và sau đó chạy image pickleball-server là dự án đã chạy được khá hoàn chỉnh. Tuy nhiên, như vậy sẽ khá mất thời gian do mỗi lần cần chạy lại project sẽ phải gõ lại khá nhiều command. Trên thực tế, chúng ta sẽ sử dụng Docker Compose để quản lý các services, networks và volumes của dự án.
- Tạo một file docker-compose.yml trong thư mục /san-pickleball/ với nội dung ban đầu như sau:
services:
client:
image: mydockerhubusername/pickleball-client:latest
container_name: nextjs-client
restart: always
ports:
- "8080:8080"
server:
image: mydockerhubusername/pickleball-server:latest
container_name: node-server
restart: always
ports:
- "2704:2704"
environment:
- ENVIROMENT=production
- MONGODB_URI=mongodb://mongo:27017/
- DATABASE_NAME=pickleball
depends_on:
- mongo
mongo:
image: mongo:6
container_name: mongo-db
restart: always
ports:
- "27018:27017"
volumes:
- mongo-data:/data/db
volumes:
mongo-data:
- Có thể thấy file được chia ra làm 3 services khá rõ ràng bao gồm client, server và mongo. Nội dung của các services thì tương đối giống nhau, bao gồm image (docker image để build container), container_name (tên containser, khi chạy
docker ps
sẽ hiển thị tên này), restart (cấu hình khởi động lại container), ports (cấu hình cổng kết nối), environment (các biến môi trường), volumes (gắn dữ liệu từ server vào container hoặc chia sẻ dữ liệu giữa các container). Chỉ có một lưu ý nhỏ là theo như flow ban đầu, Github Actions sẽ build image và đẩy lên Docker Hub, ở server chúng ta chỉ cần kéo về image về và chạy, nên ở client service và server service, chúng ta chỉ cần chỉ định pepo mà Github Action đẩy lên (ở đây là 2 repo mà trước đó chúng ta đã đẩy lên Docker Hub: mydockerhubusername/pickleball-client và mydockerhubusername/pickleball-server). - Okay, như vậy về cơ bản là đã các thiết lập cơ bản. Bây giờ, chúng ta có thể mở terminal trong thư mục /san-pickleball/ và chạy
docker compose pull
để lấy các images từ Docker Hub về và chạydocker images
để xem danh sách các images. Kết quả như sau: - Sau đó chúng ta có thể chạy
docker compose up -d
để chạy các images. Kết quả như sau: - Bây giờ có thể truy cập vào localhost:8080 để xem kết quả:
- Vậy là đã xong phần Docker?! Giờ chúng ta sẽ đến với Github Actions để tự động thực hiện các bước làm thủ công ở trên như build images, đẩy lên Docker Hub, pull về server, chạy container... IV. Github Actions
- Github Secrets
- Ở đây có thể thấy github actions sẽ đóng vai trò chính thay thế chúng ta trong việc deploy. Để có thể làm được điều này thì trước tiên ta cần cung cấp cho Github Actions một số secrets như: Docker Hub (username, token), server (host, user, key).
- Để thêm secrets vào Github Actions chúng ta sẽ làm như sau:
- B1: Tuy cập Github Repo, chọn tab Settings ở menu phía trên.
- B2: Chọn Secrets and variables => Actions ở menu bên trái.
- B3: Chọn New repository secret sau đó điền tên và giá trị của secrets vào.
- B1: Tuy cập Github Repo, chọn tab Settings ở menu phía trên.
- Như vậy là đã xong việc thêm secrets cho Github Actions. Chúng ta sẽ sử dụng những secrets này trong file deploy để kết nối với Docker Hub và server.
- Github Actions Workflow
- Về cơ bản Github Actions sẽ cho phép ta cấu hình để tự động thực hiện các hành động (job) dựa trên các sự kiện (push, create, release...) đối với repo trên github. Đối với dự án này, chúng ta mong muốn tự động thực hiện deploy dự án trên server khi push code lên github.
- Trước tiên cần tạo file có vị trí san-pickleball/.github/workflow/deploy.yml. Lúc này cấu trúc thư mục của chúng ta có dạng:
|── pickleball
|── pickleball-client/
|── dockerfile
|── pickleball-server/
|── dockerfile
|── docker-compose.yml
|── .github/
|── workflows/
|── deploy.yml
- Nội dung của file deploy.yml như sau:
name: Build and Deploy
on:
push:
branches: [main]
jobs:
# -------------------------
# Build & Push Image
# -------------------------
build:
runs-on: ubuntu-latest
strategy:
matrix:
service: [client, server]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build & Push Docker Image
uses: docker/build-push-action@v4
with:
context: ./pickleball-${{ matrix.service }}
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/pickleball-${{ matrix.service }}:latest
# -------------------------
# Deploy to Server
# -------------------------
deploy:
runs-on: ubuntu-latest
needs: [build-client, build-server]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Copy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
source: "docker-compose.yml"
target: "/home/${{ secrets.SSH_USER }}/san-pickleball/"
strip_components: 0
- name: Deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /home/${{ secrets.SSH_USER }}/san-pickleball
# Stop any old containers and remove orphans
docker compose down --remove-orphans
# Pull latest images & start all containers
docker compose pull
docker compose up -d
# Clean up unused images
docker image prune -f
- Dễ dàng thấy rằng quá trình deploy sẽ có 3 quá trình chính: build-client, build-server và deploy. build-client, build-server là 2 quá trình giống nhau hệt nhau, không phụ thuộc vào nhau, nên ta sử dụng strategy: matrix để tạo ra các job chạy song song nhằm tối ưu thời gian.
- Quá trình build và push image như sau:
- Checkout code: Kéo mã của repo để các bước sau có thể truy cập
- Log in to Docker Hub: Đăng nhập vào Docker Hub thông qua github secrets đã thêm trước đó
- Set up Docker Buildx: Thiết lập Docker Buildx, công cụ mở rộng của Docker
- Build & Push Client Docker Image: Build và push image lên Docker Hub
- Quá trình deploy:
- Checkout code: Kéo mã của repo để các bước sau có thể truy cập
- Copy files to server : Copy file docker-compose.yml vào thư mục /san-pickleball/ trên server. Cần phải tạo trước thư mục /san-pickleball/ trên server cho lần đầu.
- Deploy: Truy cập vào thư mục /san-pickleball/, chạy
docker compose pull
để kéo Docker Image về, chạydocker compose up -d
để tạo và chạy containers, networks và volumes. - Xóa các docker dangling images.
- Với file deploy.yml này, project đã có thể tự động deploy trên server khi ta push code lên github mà chỉ cần tạo thư mục /san-pickleball/ trước lần push code đầu tiên. Sau khi push, chúng ta có thể vào tab Actions trong repo trên Github để xem quá trình deploy. Kết quả như ảnh, 2 jobs build (client và server) được thực hiện song song, sau khi build xong thí job deploy được thực hiện:
- Và hiện tại cũng có thể truy cập vào project thông qua địa chỉ ip của server và cổng đã chỉ định ở dockerfile, kết quả như sau:
- Như vậy là chúng ta đã hoàn thành quy trình deploy dự án tự động mỗi khi push code thông qua Docker và Github Actions V. Config nginx và ssl
- Dự án của chúng ta đã có thể truy cập thông qua địa chỉ ip và port. Tuy nhiên, đó không phải kết quả cuối cùng chúng ta mong đợi. Một ứng dụng hoàn chỉnh cần có thể truy cập thông qua domain và được bảo mật bởi https. Để làm được điều đó, chúng ta sẽ sử dụng nginx làm reverse proxy và certbot để tự động cập nhật chứng chỉ SSL.
- Config nginx trong source code
- Trước tiên, chúng ta sẽ tạo một thư mục /nginx/, bên trong chứa một file default.conf dùng để config cho nginx. File default.conf có nội dung như sau:
# Redirect all HTTP -> HTTPS
server {
listen 80;
server_name sanpickleball.xyz www.sanpickleball.xyz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl;
server_name sanpickleball.xyz www.sanpickleball.xyz;
# SSL certs issued by certbot
ssl_certificate /etc/letsencrypt/live/sanpickleball.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sanpickleball.xyz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Node.js API
location /api/ {
proxy_pass http://server:2704/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
# Next.js client
location / {
proxy_pass
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
}
}
- File config này gồm 2 phần chính: Đầu tiên là lắng nghe trên cổng 80 (tức http) và chuyển hướng tất cả đến https. Config thứ hai là config xử lý https request bao gồm lắng nghe trên cổng 443 (tức https), sử dụng các chứng chỉ do certbot cung cấp, sử dụng TLS, chuyển tiếp url tới server hoặc client...
- Thêm container nginx và certbot trong docker
- Sau khi đã có file nginx config, chúng ta sẽ thêm các services nginx và certbot trong docker-compose.yml như sau:
services:
client:
image: mydockerhubusername/pickleball-client:latest
container_name: nextjs-client
restart: always
ports:
- "8080:8080"
server:
image: mydockerhubusername/pickleball-server:latest
container_name: node-server
restart: always
ports:
- "2704:2704"
environment:
- ENVIROMENT=production
- MONGODB_URI=mongodb://mongo:27017/
- DATABASE_NAME=pickleball
depends_on:
- mongo
mongo:
image: mongo:6
container_name: mongo-db
restart: always
ports:
- "27018:27017"
volumes:
- mongo-data:/data/db
nginx:
image: nginx:alpine
container_name: nginx-proxy
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- certbot-etc:/etc/letsencrypt # <--- shared with certbot
- certbot-var:/var/lib/letsencrypt # <--- shared with certbot
- ./nginx/www:/var/www/certbot # <--- webroot for certbot
depends_on:
- client
- server
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- certbot-etc:/etc/letsencrypt
- certbot-var:/var/lib/letsencrypt
- ./nginx/www:/var/www/certbot
entrypoint: sh -c "trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done"
volumes:
mongo-data:
certbot-etc:
certbot-var:
Hãy cùng giải thích code của service nginx:
- ports: Hiển thị các ports 80(http) và 443(https) ra bên ngoài
- Volumes:
./nginx/default.conf:/etc/nginx/conf.d/default.conf
: Gắn file default.conf tạo ở trên vào containercertbot-etc:/etc/letsencrypt
: Lưu trữ chứng chỉ ssl (ví dụ:fullchain.pem
,privkey.pem
). Sẽ được chia sẻ với service certbot để cả hai có thể đọc/ghi.certbot-var:/var/lib/letsencrypt
: tệp làm việc tạm thời cho certbot. Cũng sẽ được chia sẻ với service certbot../nginx/www:/var/www/certbot
: Mỗi khi certbot gia hạn chứng chỉ ssl, nó sẽ tạo một challenge file và Let's Encrypt sẽ kiểm tra challenge file thông qua nginx. Vậy nên ta sẽ cần một folder để chứa challenge file đó. Folder này phải được chia sẻ với cả container nginx và container certbot.
- depends_on: chạy sau service client và service server. Và code của service certbot:
- Volumes :
certbot-etc:/etc/letsencrypt
vàcertbot-var:/var/lib/letsencrypt
: Chia sẻ với nginx, khi certbot tạo chứng chỉ, nginx có thể sử dụng được ngay lập tức../nginx/www:/var/www/certbot
: Giống như giải thích ở service nginx.
- Entrypoint: Tự động chạy lại sau 12h để kiểm tra và gia hạn chứng chỉ.
- Sửa file deploy.yml
- Giống như docker-compose.yml, trên server của chúng ta cũng chưa có file nginx/default.conf, vậy nên chúng ta sẽ copy từ source trên github vào server giống như docker-compose.yml.
- Như đã đề cập ở trên, ta đã mounted thư mục /nginx/www với thư mục /var/www/certbot, tuy nhiên hiện tại ở trên server của chúng ta chưa có thư mục /nginx/www. Vậy nên, ở deploy.yml, chúng ta sẽ cần tạo thư mục nginx/www này trước khi chạy các container.
- Code update của job deploy như sau:
# -------------------------
# Deploy to Server
# -------------------------
deploy:
runs-on: ubuntu-latest
needs: [build-client, build-server]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Copy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
source: "docker-compose.yml,nginx/default.conf"
target: "/home/${{ secrets.SSH_USER }}/san-pickleball/"
strip_components: 0
- name: Deploy
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /home/${{ secrets.SSH_USER }}/san-pickleball
# Ensure webroot exists
mkdir -p ./nginx/www
# Stop any old containers and remove orphans
docker compose down --remove-orphans
# Pull latest images & start all containers
docker compose pull
docker compose up -d
# Clean up unused images
docker image prune -f
- Lấy chứng chỉ SSL cho lần đầu
Vậy là đã xong? Giờ dự án của chúng ta đã tự động deploy khi push code 100%? Rất tiếc là chưa, chúng ta vẫn cần làm thêm một việc. Như đã thấy ở trên, chúng ta mới chỉ làm certbot gia hạn chứng chỉ ssl, nhưng chứng chỉ ssl ban đầu thì chưa có. Vì vậy, chúng ta cũng cần tạo chứng chỉ ssl cho lần đầu tiên. Cách thực hiện như sau: - Vào thư mục san-pickleball trên server, chạy command:
docker compose run --rm certbot certonly --webroot \ --webroot-path=/var/www/certbot \ --email your@email.com \ --agree-tos \ --no-eff-email \ -d yourdomain.com -d www.yourdomain.com
- Với /var/www/certbot là thư mục chia sẻ giữa container nginx và container certbot để Let's Encrypt xác minh như đã đề cập đến ở trên, your@email.com là email của bạn để nhận thông báo hết hạn chứng chỉ, yourdomain.com là domain của trang web. - Khi chạy lệnh này, certbot sẽ yêu cầu Let's Encrypt cấp chứng chỉ, sau khi lấy chứng chỉ thành công, certbot tải chứng chỉ vào /etc/letsencrypt/live/yourdomain.com, container sẽ được xóa đi. Như vậy hiện tại server đã có chứng chỉ ssl, sau này certbot chỉ cần gia hạn. VI. Tổng kếtTổng kết lại quá trình như sau:
- Tạo các file config như trong cấu trúc:
|── pickleball |── pickleball-client/ |── dockerfile |── pickleball-server/ |── dockerfile |── docker-compose.yml |── .github/ |── workflows/ |── deploy.yml |── .nginx/ |── default.conf
- Vào server, tạo thư mục chứa docker-compose.yml và /nginx/default.conf
- Deploy code lên github, lúc này Github Actions sẽ chạy.
- Sau khi Github Actions chạy xong, vào server chạy command ở mục V.3 để lấy chứng chỉ ssl cho lần đầu.
- Như vậy là đã xong. Từ sau, chúng ta chỉ cần push code là dự án sẽ tự động được deploy.
Như vậy mình đã trình bày flow và cách thực hiện tự động hóa quá trình deploy dự án mỗi khi push code lên github. Mặc dù chưa tự động 100%, tuy nhiên cũng giúp cho mình tiết kiệm khá nhiều thời gian khi cần di chuyển server thường xuyên. Bài viết dựa trên kinh nghiệm cá nhân nên vẫn còn thiếu sót, nếu bạn nào có kinh nhiệm về vấn đề này thì có thể chia sẻ trong comment để mình và mọi người tham khảo thêm nhé.