Deploy MERN Stack on AWS EC2 with Docker via GitHub Actions

按照帖子:Deploy MERN Stack on AWS EC2 with Docker via GitHub Actions部署我的新项目及过程中遇到的问题

Deploy MERN Stack on AWS EC2 with Docker via GitHub Actions

如何部署

将 MERN 项目的前端后端分别放在 github 两个仓库中,使用 github action,我们将分别为这两个存储库构建 Docker 镜像。这些镜像将被推送到 DockerHub 并保存在 DockerHub 上的两个不同的存储库中。在 AWS EC2 上,我们分别为前端后端配置 self-host runner。当我们的镜像推送到 dockerhub 后,runner 会自动拉取镜像并在 EC2 上运行。

Architechture

Step1: 在 DockerHub 创建 public Respository

登录到 dockerhub,如果是第一次注册需要记下账号和密码,这很重要;创建两个公共存储库,一个用于前端,另一个用于后端。

image-20250707110524333

当我们在 GitHub 上为项目创建存储库时,我们必须将 DockerHub 用户名密码存储在 GitHub Secrets 中。

Step 2: 允许访问 MongoDB 数据库

因为我的 MERN 项目使用的是 MongoDB Atlas,这允许我在线访问数据库,因此我需要配置从 Internet 上的任何位置访问 MongoDB。登录后,左侧导航栏 -> Security -> Network Access

11MongoDb

现在,您将看到数据库的 IP 访问列表。如果您看到 IP“0.0.0.0/0”,则可以从任何地方访问它。如果您看到任何其他 IP 地址,请删除该地址并输入给定的 IP 地址。

MongoDB

Step 3: 推送代码到 Github 仓库并配置 GitHub Secrets

MERN 项目中应该有两个文件夹,一个前端,一个后端,分别连接创建好的 Github 仓库

image-20250707110711615

为前端和后端创建两个不同的存储库,并将前端文件夹文件推送到前端 GitHub 存储库,将后端文件夹文件推送到项目的后端 GitHub 存储库。

image-20250707110844905

新建两个仓库的GitHub Secrets:Setting -> 左侧导航栏 -> Security -> secrets and variables -> Action -> New repository secrets

image-20250707111724897

GitHub Secrets 作用:在代码中对我们的用户名、密码和 API 进行硬编码不是好的做法。为了在生产环境中保护我们的凭证,GitHub 提供了添加密钥的功能。这些密钥可以是我们在开发时添加的“env”变量、连接密钥或我们的凭证。

⚠️:这里两个仓库都需要添加 DockerHub 的用户名及密码,用于后面 Github Action push 和 pull 镜像。

Step 4: 创建 EC2 实例

因为我之前已经申请过 EC2,所以这一步不做赘述,第一次申请详见原贴 Step 4: Deploy MERN Stack on AWS EC2 with Docker via GitHub Actions

这里需要额外补充的是:

  1. 如果想要在本地登录到 EC2,需要新建 key-pair,点击创建会自动下载到电脑,复制保存到~/.ssh/文件夹

    打开本地 terminal,输入ssh -i ~/.ssh/kp-mac.pem ubuntu@your ipaddress 其中 kp-mac.pem 是下载的 key-pair

  2. EC2 同一个实例可以跑很多不同的项目,只需要新建镜像,开不同的端口,最后用 Nginx 指定端口即可,这样可以节省很多额度。

  3. 新建端口:选中已有的 Instance -> Security -> Security groups -> Inbound rules -> Edit Inbound rules -> Add rules

    image-20250707113811566

Step 5: 在 EC2 实例上安装 Docker

登录到 EC2,两种登录方式,一种是在线登录,一种本地登录

在线登录

选中 Instance -> Connect

image-20250707114149496

选择“EC2 Instance Connect”,选择 ip,然后单击 Connect。

image-20250707114243626

现在我们将看到一个 AWS 终端。

image-20250707114418869

本地登录

本地终端输入:ssh -i ~/.ssh/kp-mac.pem ubuntu@your_ip_address,也会进入到 AWS 终端

image-20250707114632558

安装 Docker

先验证 Docker 是否安装,附图是没有安装的结果

1
2
# 验证docker是否安装
sudo docker --version

docker --version

如果没有安装,则按照下面命令安装

1
2
3
4
5
sudo apt-get update
sudo apt-get install docker.io -y
sudo systemctl start docker
sudo docker run hello-world
docker ps

此时命令 docker ps 报错:permission denied,是因为我们没给他授权

docker ps

授权后 docker ps 命令就不再报错了

1
2
3
4
# 如果上一行命令docker ps报错:permission denied,我们要给他授权
sudo chmod 666 /var/run/docker.sock
sudo systemctl enable docker
docker --version

Successfully installed Docker

现在,我们成功在 EC2 上安装 docker 并授权

Step 6: 在 EC2 上创建 Self-Hosted Runners

‼️ 转到 Github 中,针对前端后端仓库分别创建 runner

github -> bookhub_backend -> Settings -> Actions -> Runners -> New-self-hosted runner

image-20250707115852362

我们的 EC2 实例是 Ubuntu,因此请为运行器映像选择“Linux”选项。然后你将看到用于创建运行程序的命令;逐个复制这些命令并在我们的 EC2 终端上运行。

image-20250707120018844

需要注意的点:

  1. 创建文件夹时需要sudo
  2. add the name of the runner, “aws-ec2.”
  3. 其他都默认配置

将自托管运行器配置为服务:要在终端中将 Runner 配置为服务,请运行以下命令:

1
2
3
sudo ./svc.sh install
sudo ./svc.sh start
# sudo ./svc.sh stop 是停止的命令,后面重启runner会用到

重复上述步骤,分别为前端后端创建 runner,如果有疑问请详见原贴:Step 6: Deploy MERN Stack on AWS EC2 with Docker via GitHub Actions

image-20250707120757970

Step 7: 为后端仓库创建 Dockerfile 和 CICD 工作流

image-20250707122123858

我的项目后端是TypeScript,Node.js 本身只能识别JavaScript,直接 node index.ts 会报找不到模块或语法错误。要在容器里“运行”TypeScript,需要先用 tsc 编译,再跑编译结果

部署

在项目根目录里加上 tsconfig.json

1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
"outDir": "./dist",
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

修改 package.json

1
2
3
4
5
{
"scripts": {
"build": "npx tsc", // 生成/dist
"start": "node ./dist/index.js", // 跑 dist 文件夹中的 js 文件
},

项目根目录加上 dockerfile

1
2
3
4
5
6
7
8
9
10
11
FROM node:20-alpine3.18
WORKDIR /src
# 先复制并安装依赖(包含 devDependencies)
COPY package.json package-lock.json tsconfig.json ./
RUN npm install
# 拷入源码并编译
COPY . .
RUN npm run build # 之前没有编译,现在需要先build再运行
EXPOSE 5555
# 只运行编译后的 JS
CMD [ "npm", "run", "start" ]

创建工作流

现在在backend文件夹下创建一个.github文件夹。在.github文件夹中,创建workflow文件夹,并在此文件夹中创建文件cicd.yml

image-20250707122341134
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# cicd.yml
name: Deploy Your_GitHub_Repository_Name
on:
push:
branches:
- main

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Source
uses: actions/checkout@v4
- name: Login to docker hub
run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
run: docker build -t DockerHub_Username/Repository_Name .
- name: Publish Image to docker hub
run: docker push DockerHub_Username/Repository_Name:latest

deploy:
needs: build
runs-on: self-hosted
steps:
- name: Pull image from docker hub
run: sudo docker pull DockerHub_Username/Repository_Name:latest
- name: Delete old container
run: sudo docker rm -f bookhub-backend-container
# 这里与原贴不同,因为我有很多环境变量因此我创建了一个 env 文件,在 docker run 时用--env-file 导入,可以减少工作量
- name: Generate .env file
run: |
cat <<EOF > .env
DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }}
JWT_COOKIE_EXPIRES_IN=${{ secrets.JWT_COOKIE_EXPIRES_IN }}
JWT_EXPIRES_IN=${{ secrets.JWT_EXPIRES_IN }}
JWT_SECRET=${{ secrets.JWT_SECRET }}
MONGO_CONNECTION_STRING=${{ secrets.MONGO_CONNECTION_STRING }}
FRONTEND_URL=${{ secrets.FRONTEND_URL }}
EOF
- name: Run Docker Container
# 下面的5555:3001 很重要,这里的 5555 是我在 EC2 上为后端指定的端口,3001 是我在代码中指定的端口,这里可以在代码中传入 5555,也可以按照我下面这样进行映射
run: sudo docker run -d -p 5555:3001 --name bookhub-backend-container --env-file .env DockerHub_Username/Repository_Name

完成以上配置后,推送代码到 github,github action 会自动构建和部署,成功完成这些步骤后,我们的后端应用程序会部署到 AWS EC2

image-20250707123249846

验证部署

验证 cicd 是否正常,ssh 到 ec2

1
2
3
4
5
6
7
8
sudo docker ps # 看所有跑起来的docker进程
sudo docker logs bookhub-frontend-container # 看log

sudo docker images # 列出所有镜像
sudo docker rmi <image> # 删除不需要的镜像

sudo docker ps -a # 列出所有容器
sudo docker rm <container> # 删除不需要的容器

打开后端链接:http://:,按照你自己定义的 api 进行验证

当然,一次性部署成功是小概率的,可能遇到各种各样的问题,需要根据报错信息逐步定位,下面是我部署过程中遇到的问题

遇到的问题

  1. 本地 docker build 报错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    docker build -t ella0110/bookhub_backend  .
    [+] Building 31.4s (3/3) FINISHED docker:desktop-linux
    => [internal] load build definition from Dockerfile 0.1s
    => => transferring dockerfile: 195B 0.0s
    => ERROR [internal] load metadata for docker.io/library/node:alpine3.18 31.1s
    => [auth] library/node:pull token for registry-1.docker.io 0.0s
    ------
    > [internal] load metadata for docker.io/library/node:alpine3.18:
    ------
    Dockerfile:1
    --------------------
    1 | >>> FROM node:alpine3.18
    2 | WORKDIR /src
    3 | COPY package.json ./
    --------------------
    ERROR: failed to solve: DeadlineExceeded: DeadlineExceeded: DeadlineExceeded: node:alpine3.18: failed to resolve source metadata for docker.io/library/node:alpine3.18: failed to authorize: DeadlineExceeded: failed to fetch oauth token: Post "https://auth.docker.io/token": dial tcp 103.97.3.19:443: i/o timeout

    原因:Docker daemon 在拉取 node:alpine3.18 镜像时,连不上 Docker Hub 的 Registry / Auth 服务,网络请求超时

    解决:ERROR: failed to solve: node:18-alpine 解决办法,先 pull 再 build

  2. github action deploy 报错:

    1
    permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/images/create?fromImage=ella0110%2Fbookhub_frontend&tag=latest": dial unix /var/run/docker.sock: connect: permission denied

    原因:docker 没有 sudo 权限,赋予权限,然后重启 runner

    1
    2
    3
    4
    5
    # 把 ubuntu 加到 docker 组
    sudo usermod -aG docker ubuntu
    cd /home/ubuntu/actions-runner-backend/
    sudo ./svc.sh stop
    sudo ./svc.sh start
  3. 验证 cicd 是否正常,ssh 到 ec2

    1
    2
    3
    4
    5
    6
    7
    8
    sudo docker ps # 看所有跑起来的 docker 进程
    sudo docker logs bookhub-frontend-container # 看 log

    sudo docker images # 列出所有镜像
    sudo docker rmi <image> # 删除不需要的镜像

    sudo docker ps -a # 列出所有容器
    sudo docker rm <container> # 删除不需要的容器
  4. 看 log 的报错:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    sudo docker logs bookhub-backend-container

    > backend@1.0.0 start
    > node index.js

    node:internal/modules/cjs/loader:1189
    throw err;
    ^

    Error: Cannot find module '/src/index.js'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1186:15)
    at Module._load (node:internal/modules/cjs/loader:1012:27)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:158:12)
    at node:internal/main/run_main_module:30:49 {
    code: 'MODULE_NOT_FOUND',
    requireStack: []
    }

    这里的 index.js 修改了很多版,包括:把 index.ts 移到根目录下/修改 package.json 为"start": "node src/index.js"

    根本原因是:我的代码是 typescript 写的,但这里跑的是 js 的主入口,我指定的是 src 下的 ts 文件,需要增加 dist 编译步骤

  5. Github Action 多个环境变量注入

    使用 --env-file,可以在工作目录里动态生成一个 .env,然后让 Docker 一次性加载:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    - name: Generate .env file
    run: |
    cat <<EOF > .env
    MONGO_PASSWORD=${{ secrets.MONGO_PASSWORD }}
    DB_USER=${{ secrets.DB_USER }}
    DB_PASS=${{ secrets.DB_PASS }}
    API_KEY=${{ secrets.API_KEY }}
    EOF

    - name: Run container with env-file
    run: |
    sudo docker run -d \
    --name bookhub-backend-container \
    -p 5555:5555 \
    --env-file .env \
    ella0110/bookhub_backend:latest

    .env 文件就像本地开发时的那样,变量多了也很整洁。、

  6. 我的 mern 后端中,index 指定端口为 3001,但是 aws ec2 我开了两个 inbound 端口,分别为前端和后端,后端是 5555,现在我 docker 在 ec2 中正常跑起来了,我想测试后端,但是在浏览器输入http://<Your-EC2-Public-IP>:5555/api/hotels/显示This page isn’t working<Your-EC2-Public-IP> is currently unable to handle this request. HTTP ERROR 502

    原因:在 cicd.yml 中,写的是sudo docker run -d -p 5555:5555 … ella0110/bookhub_backend,同时也没有向里传入 PORT 环境变量,这相当于把宿主机的 5555 转到容器的 5555,可是容器里根本没服务在 5555 上,于是请求转发失败,Nginx/Docker proxy 拿不到后端返回,就返回了 502。

    解决 1:把宿主的 5555 映射到容器的 3001

    1
    2
    3
    4
    5
    sudo docker run -d \
    -p 5555:3001 \
    --name bookhub-backend-container \
    -e MONGO_PASSWORD='你的 Mongo 密码' \
    ella0110/bookhub_backend:latest

    解决 2:传入 PORT=5555

Step 8: 部署前端,写 dockerfile, cicd.yml,步骤同后端

但有一些区别,前端的静态文件在 build 阶段就已经确定了,所以不能将 github secrets 在 docker run 时传入,需要提前导入

部署

dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM node:alpine3.18 as build
# Build App
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .

# 声明两个 build-arg
ARG VITE_API_BASE_URL
ARG VITE_STRIPE_PUB_KEY
# 然后设置成环境变量,Vite build 时就会替换
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ENV VITE_STRIPE_PUB_KEY=${VITE_STRIPE_PUB_KEY}

RUN npm run build

# Serve with Nginx
FROM nginx:1.23-alpine
WORKDIR /usr/share/nginx/html
RUN rm -rf *
COPY --from=build /app/dist .
EXPOSE 80
ENTRYPOINT [ "nginx", "-g", "daemon off;" ]

Cicd.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
name: Deploy bookhub-frontend

on:
push:
branches:
- main

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Source
uses: actions/checkout@v4
- name: Login to docker hub
run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
# 在 dockerfile 中定义的 build-arg 在这里导入
run: docker build --build-arg VITE_API_BASE_URL="${{ secrets.VITE_API_BASE_URL }}" --build-arg VITE_STRIPE_PUB_KEY="${{ secrets.VITE_STRIPE_PUB_KEY }}" -t DockerHub_Username/Repository_Name .

- name: Publish Image to docker hub
run: docker push DockerHub_Username/Repository_Name:latest

deploy:
needs: build
runs-on: self-hosted
steps:
- name: Pull image from docker hub
run: docker pull DockerHub_Username/Repository_Name:latest
- name: Delete old container
run: docker rm -f bookhub-frontend-container
- name: Run Docker Container
run: docker run -d -p 5173:80 --name bookhub-frontend-container DockerHub_Username/Repository_Name

遇到的问题:

  1. github action deploy 报错:permission denied while trying to connect to the Docker daemon socket

    原因:docker 没有 sudo 权限,赋予权限,然后重启 runner

    1
    2
    3
    4
    5
    6
    # 把 ubuntu 加到 docker 组
    sudo usermod -aG docker ubuntu
    # 重启 runner
    cd /home/ubuntu/actions-runner-frontend/
    sudo ./svc.sh stop
    sudo ./svc.sh start

    另外,这里给 ubuntu 赋权限是因为,ps aux | grep runsvc.sh,最左边的就是用户组

    image-20250706231236611

  2. 前端展示出来了,但是没有图片,后端连接失败,console 中显示报错:

    1
    2
    index-CMKHi_ND.js:42
    GET http://<Your-EC2-Public-IP>:5173/api/hotels 404 (Not Found),

    原因:github action 环境变量原来的写法在 docker run 是才导入,但是前端静态文件的编译在 docker build 阶段就已经确定了,所以要把环境变量提前到 docker build 阶段

    1
    2
    3
    4
    5
    6
    7
    8
    # dockerfile

    # 接收外部参数,并设为环境变量,Vite 会在 build 时替换
    ARG VITE_API_BASE_URL
    ENV VITE_API_BASE_URL=$VITE_API_BASE_URL

    COPY . .
    RUN npm run build
    1
    2
    3
    # cicd.yml

    docker build --build-arg VITE_API_BASE_URL="${{ secrets.VITE_API_BASE_URL }}" --build-arg VITE_STRIPE_PUB_KEY="${{ secrets.VITE_STRIPE_PUB_KEY }}" -t ella0110/bookhub_frontend .
  3. 跨域请求报错:

    1
    Access to fetch at 'http://<Your-EC2-Public-IP>:5555/api/hotels' from origin 'http://<Your-EC2-Public-IP>:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

    原因:在后端没有配置前端的跨域 url,在后端的 github action 新增变量:FRONTEND_URL 指向前端链接及端口

    1
    2
    3
    4
    5
    6
    7
    app.use(
    cors({
    // 让我们的服务器只接收这个链接的请求,并且这个 URL 必须携带 credentials,也就是要有 cookies
    origin: process.env.FRONTEND_URL,
    credentials: true,
    })
    );

域名解析及 Nginx 反向代理

  1. 我有一个域名 trillobe.com,我想把 bookhub.trillobe.com 映射到 http://:5173/,但我现在已经有一个 api.trillobe.com 映射到 http://了,我该怎么办

    解决:DNS 只能把域名指向 IP,不能指定端口。增加 A 记录

    1
    2
    3
    主机记录	类型	记录值	说明
    bookhub A <Your-EC2-Public-IP> bookhub.trillobe.com 指向前端服务器
    api.bookhub A <Your-EC2-Public-IP> api.bookhub.trillobe.com 指向后端服务器

    此时 bookhub.trillobe.com 和 api.bookhub.trillobe.com 打开应该显示这个界面(因为之前我的 ec2 已经安装过 nginx 了,如果还没安装需要安装后才会显示下面的界面。另外还没配证书所以还是 http 的,显示 Not Secure)

    image-20250706222411776