From e783c5f1e7b4c086d5fc63433bfbc56f0821a1c6 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Thu, 14 May 2026 10:21:42 +0200 Subject: [PATCH] feat: implement Docker-based production deployment pipeline with automated Ansible provisioning, CI/CD workflows, and Caddy server configuration. --- .github/workflows/deploy-dev.yml | 30 ++++++++++ .github/workflows/deploy-prod.yml | 32 +++++++++++ .github/workflows/on-create-tag.yml | 41 ++++++++++++++ .github/workflows/on-pull-request-to-main.yml | 20 +++++++ .github/workflows/on-push-main.yml | 21 +++++++ .github/workflows/publish-image.yml | 56 +++++++++++++++++++ .github/workflows/test.yml | 48 ++++++++++++++++ Caddyfile | 37 ++++++++++++ Caddyfile.test | 25 +++++++++ Dockerfile | 29 ++++------ infra/development/hosts.ini | 2 + infra/development/site/compose.yml | 13 +++++ infra/development/site/deploy-playbook.yml | 51 +++++++++++++++++ infra/production/hosts.ini | 2 + infra/production/site/compose.yml | 13 +++++ infra/production/site/deploy-playbook.yml | 51 +++++++++++++++++ 16 files changed, 453 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/deploy-dev.yml create mode 100644 .github/workflows/deploy-prod.yml create mode 100644 .github/workflows/on-create-tag.yml create mode 100644 .github/workflows/on-pull-request-to-main.yml create mode 100644 .github/workflows/on-push-main.yml create mode 100644 .github/workflows/publish-image.yml create mode 100644 .github/workflows/test.yml create mode 100644 Caddyfile create mode 100644 Caddyfile.test create mode 100644 infra/development/hosts.ini create mode 100644 infra/development/site/compose.yml create mode 100644 infra/development/site/deploy-playbook.yml create mode 100644 infra/production/hosts.ini create mode 100644 infra/production/site/compose.yml create mode 100644 infra/production/site/deploy-playbook.yml diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..1d6bd7b --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,30 @@ +name: Deploy to Dev + +on: + workflow_call: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Ansible and collections + run: | + pip install --break-system-packages ansible + ansible-galaxy collection install ansible.posix community.docker + + - name: Set up SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SRV_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + + - name: Run Ansible playbook + run: | + ansible-playbook \ + -i infra/development/hosts.ini \ + -e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \ + infra/development/site/deploy-playbook.yml diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..a697367 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,32 @@ +name: Deploy to Production + +on: + workflow_call: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + + - name: Install Ansible and collections + run: | + pip install --break-system-packages ansible + ansible-galaxy collection install ansible.posix community.docker + + - name: Set up SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SRV_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + + - name: Run Ansible playbook + run: | + ansible-playbook \ + -i infra/production/hosts.ini \ + -e "ansible_ssh_private_key_file=~/.ssh/deploy_key" \ + infra/production/site/deploy-playbook.yml diff --git a/.github/workflows/on-create-tag.yml b/.github/workflows/on-create-tag.yml new file mode 100644 index 0000000..7944f38 --- /dev/null +++ b/.github/workflows/on-create-tag.yml @@ -0,0 +1,41 @@ +name: On Create Tag + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +jobs: + publish: + uses: ./.github/workflows/publish-image.yml + with: + build_mode: production + secrets: inherit + + update-compose: + needs: publish + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + token: ${{ github.token }} + + - name: Update image tag in production compose.yml + run: | + VERSION=${GITHUB_REF_NAME#v} + sed -i "s|\(image:.*:\)[^[:space:]]*|\1${VERSION}|" infra/production/site/compose.yml + + - name: Commit and push + run: | + git config user.name "respellion-bot" + git config user.email "no-reply.git@respellion.nl" + git add infra/production/site/compose.yml + git commit -m "chore: update prod image to ${GITHUB_REF_NAME#v}" + git push origin main + + deploy-prod: + needs: update-compose + uses: ./.github/workflows/deploy-prod.yml + secrets: inherit diff --git a/.github/workflows/on-pull-request-to-main.yml b/.github/workflows/on-pull-request-to-main.yml new file mode 100644 index 0000000..2eb8fba --- /dev/null +++ b/.github/workflows/on-pull-request-to-main.yml @@ -0,0 +1,20 @@ +name: On Pull Request to Main + +on: + pull_request: + branches: [main] + workflow_call: + +jobs: + test: + uses: ./.github/workflows/test.yml + + publish: + needs: test + uses: ./.github/workflows/publish-image.yml + secrets: inherit + + deploy-dev: + needs: publish + uses: ./.github/workflows/deploy-dev.yml + secrets: inherit diff --git a/.github/workflows/on-push-main.yml b/.github/workflows/on-push-main.yml new file mode 100644 index 0000000..f057a69 --- /dev/null +++ b/.github/workflows/on-push-main.yml @@ -0,0 +1,21 @@ +name: On Push to Main + +on: + push: + branches: [main] + +jobs: + test: + uses: ./.github/workflows/test.yml + + publish: + needs: test + uses: ./.github/workflows/publish-image.yml + with: + build_mode: development + secrets: inherit + + deploy-dev: + needs: publish + uses: ./.github/workflows/deploy-dev.yml + secrets: inherit diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..fd153dc --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,56 @@ +name: Publish Image + +on: + workflow_call: + inputs: + build_mode: + description: "Build mode (development or production)" + type: string + default: production + workflow_dispatch: + inputs: + build_mode: + description: "Build mode (development or production)" + type: string + default: production + +env: + REGISTRY: git.labs.respellion.tech + IMAGE: respellion/learning-platform/learning-platform + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: respellion-bot + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE }} + tags: | + type=sha,format=short,prefix= + type=raw,value=latest + type=semver,pattern={{version}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUILD_MODE=${{ inputs.build_mode }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c41f013 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +name: Test + +on: + workflow_call: + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t learning-platform . + + - name: Build test image + run: | + cat > Dockerfile.test <<'EOF' + FROM learning-platform + COPY Caddyfile.test /etc/caddy/Caddyfile + EOF + docker build -t learning-platform-test -f Dockerfile.test . + + - name: Start container + run: | + docker run -d --network gitea --name test-container learning-platform-test + sleep 5 + + - name: Test homepage + run: | + status=$(curl -s -o /dev/null -w "%{http_code}" http://test-container) + if [ "$status" != "200" ]; then + echo "Homepage failed with status $status" + exit 1 + fi + echo "✓ Homepage OK" + + - name: Test security headers + run: | + headers=$(curl -sI http://test-container) + echo "$headers" | grep -q "X-Frame-Options" || (echo "Missing X-Frame-Options" && exit 1) + echo "$headers" | grep -q "X-Content-Type-Options" || (echo "Missing X-Content-Type-Options" && exit 1) + echo "✓ Security headers OK" + + - name: Cleanup + if: always() + run: docker stop test-container && docker rm test-container diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..88cf796 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,37 @@ +:80 { + root * /srv + file_server + + encode gzip zstd + + header { + X-Frame-Options "SAMEORIGIN" + X-Content-Type-Options "nosniff" + X-XSS-Protection "1; mode=block" + Referrer-Policy "strict-origin-when-cross-origin" + Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + } + + @static { + path *.css *.js *.png *.jpg *.jpeg *.gif *.webp *.svg *.woff *.woff2 *.ttf + } + header @static Cache-Control "public, max-age=31536000, immutable" + + @html { + path *.html / + } + header @html Cache-Control "public, max-age=0, must-revalidate" + + try_files {path} /index.html + + handle_errors { + rewrite * /index.html + file_server + } + + log { + output stdout + format console + } +} diff --git a/Caddyfile.test b/Caddyfile.test new file mode 100644 index 0000000..e385fac --- /dev/null +++ b/Caddyfile.test @@ -0,0 +1,25 @@ +:80 { + root * /srv + file_server + + encode gzip + + header { + X-Frame-Options "SAMEORIGIN" + X-Content-Type-Options "nosniff" + X-XSS-Protection "1; mode=block" + Referrer-Policy "strict-origin-when-cross-origin" + } + + try_files {path} /index.html + + handle_errors { + rewrite * /index.html + file_server + } + + log { + output stdout + format console + } +} diff --git a/Dockerfile b/Dockerfile index 404cb86..3d50513 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,21 @@ -# Stage 1: Build the React application -FROM node:22-alpine as build +FROM node:24-alpine AS builder WORKDIR /app -# Copy package files and install dependencies -COPY package*.json ./ -RUN npm install +COPY package.json package-lock.json ./ +RUN npm ci -# Copy the rest of the application files COPY . . -# Build the Vite project for production -RUN npm run build +ARG BUILD_MODE=development +RUN npm run build -- --mode $BUILD_MODE -# Stage 2: Serve the application with Nginx -FROM nginx:alpine +FROM caddy:2-alpine -# Copy custom Nginx configuration -COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /srv +COPY Caddyfile /etc/caddy/Caddyfile -# Copy the build output to Nginx's html directory -COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 443 -# Expose port 80 -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 diff --git a/infra/development/hosts.ini b/infra/development/hosts.ini new file mode 100644 index 0000000..721dd3e --- /dev/null +++ b/infra/development/hosts.ini @@ -0,0 +1,2 @@ +[development] +development ansible_host=46.224.220.37 ansible_port=4484 ansible_user=root diff --git a/infra/development/site/compose.yml b/infra/development/site/compose.yml new file mode 100644 index 0000000..e17ddf4 --- /dev/null +++ b/infra/development/site/compose.yml @@ -0,0 +1,13 @@ +version: "3" + +networks: + learning-platform: + external: true + +services: + learning-platform: + image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:latest + container_name: learning-platform + restart: unless-stopped + networks: + - learning-platform diff --git a/infra/development/site/deploy-playbook.yml b/infra/development/site/deploy-playbook.yml new file mode 100644 index 0000000..64dc737 --- /dev/null +++ b/infra/development/site/deploy-playbook.yml @@ -0,0 +1,51 @@ +- name: Deploy Learning Platform with Ansible + hosts: all + become: yes + gather_facts: yes + vars: + ansible_ssh_common_args: '-o StrictHostKeyChecking=no' + + tasks: + - name: Set destination directory + ansible.builtin.set_fact: + dest_dir: "/opt/learning-platform" + + - name: Ensure dependencies are installed + ansible.builtin.package: + name: + - docker-ce + state: present + ignore_errors: yes + register: docker_valid + + - name: Include geerlingguy.docker role conditionally + include_role: + name: geerlingguy.docker + when: docker_valid is failed + + - name: Ensure target directory exists + ansible.builtin.file: + path: "{{ dest_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Copy compose.yml to destination + ansible.builtin.copy: + src: compose.yml + dest: "{{ dest_dir }}/compose.yml" + + - name: Pull latest image + community.docker.docker_compose_v2: + project_src: "{{ dest_dir }}" + state: present + files: compose.yml + pull: always + + - name: Start services with Docker Compose + community.docker.docker_compose_v2: + project_src: "{{ dest_dir }}" + state: present + files: compose.yml + recreate: always diff --git a/infra/production/hosts.ini b/infra/production/hosts.ini new file mode 100644 index 0000000..96cb561 --- /dev/null +++ b/infra/production/hosts.ini @@ -0,0 +1,2 @@ +[production] +production ansible_host=91.107.213.195 ansible_port=22 ansible_user=root diff --git a/infra/production/site/compose.yml b/infra/production/site/compose.yml new file mode 100644 index 0000000..375d7f9 --- /dev/null +++ b/infra/production/site/compose.yml @@ -0,0 +1,13 @@ +version: "3" + +networks: + learning-platform: + external: true + +services: + learning-platform: + image: git.labs.respellion.tech/respellion/learning-platform/learning-platform:0.1.0 + container_name: learning-platform + restart: unless-stopped + networks: + - learning-platform diff --git a/infra/production/site/deploy-playbook.yml b/infra/production/site/deploy-playbook.yml new file mode 100644 index 0000000..64dc737 --- /dev/null +++ b/infra/production/site/deploy-playbook.yml @@ -0,0 +1,51 @@ +- name: Deploy Learning Platform with Ansible + hosts: all + become: yes + gather_facts: yes + vars: + ansible_ssh_common_args: '-o StrictHostKeyChecking=no' + + tasks: + - name: Set destination directory + ansible.builtin.set_fact: + dest_dir: "/opt/learning-platform" + + - name: Ensure dependencies are installed + ansible.builtin.package: + name: + - docker-ce + state: present + ignore_errors: yes + register: docker_valid + + - name: Include geerlingguy.docker role conditionally + include_role: + name: geerlingguy.docker + when: docker_valid is failed + + - name: Ensure target directory exists + ansible.builtin.file: + path: "{{ dest_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Copy compose.yml to destination + ansible.builtin.copy: + src: compose.yml + dest: "{{ dest_dir }}/compose.yml" + + - name: Pull latest image + community.docker.docker_compose_v2: + project_src: "{{ dest_dir }}" + state: present + files: compose.yml + pull: always + + - name: Start services with Docker Compose + community.docker.docker_compose_v2: + project_src: "{{ dest_dir }}" + state: present + files: compose.yml + recreate: always