feat: implement Docker-based production deployment pipeline with automated Ansible provisioning, CI/CD workflows, and Caddy server configuration.
This commit is contained in:
30
.github/workflows/deploy-dev.yml
vendored
Normal file
30
.github/workflows/deploy-dev.yml
vendored
Normal file
@@ -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
|
||||||
32
.github/workflows/deploy-prod.yml
vendored
Normal file
32
.github/workflows/deploy-prod.yml
vendored
Normal file
@@ -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
|
||||||
41
.github/workflows/on-create-tag.yml
vendored
Normal file
41
.github/workflows/on-create-tag.yml
vendored
Normal file
@@ -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
|
||||||
20
.github/workflows/on-pull-request-to-main.yml
vendored
Normal file
20
.github/workflows/on-pull-request-to-main.yml
vendored
Normal file
@@ -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
|
||||||
21
.github/workflows/on-push-main.yml
vendored
Normal file
21
.github/workflows/on-push-main.yml
vendored
Normal file
@@ -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
|
||||||
56
.github/workflows/publish-image.yml
vendored
Normal file
56
.github/workflows/publish-image.yml
vendored
Normal file
@@ -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 }}
|
||||||
48
.github/workflows/test.yml
vendored
Normal file
48
.github/workflows/test.yml
vendored
Normal file
@@ -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
|
||||||
37
Caddyfile
Normal file
37
Caddyfile
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Caddyfile.test
Normal file
25
Caddyfile.test
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
29
Dockerfile
29
Dockerfile
@@ -1,28 +1,21 @@
|
|||||||
# Stage 1: Build the React application
|
FROM node:24-alpine AS builder
|
||||||
FROM node:22-alpine as build
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files and install dependencies
|
COPY package.json package-lock.json ./
|
||||||
COPY package*.json ./
|
RUN npm ci
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
# Copy the rest of the application files
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the Vite project for production
|
ARG BUILD_MODE=development
|
||||||
RUN npm run build
|
RUN npm run build -- --mode $BUILD_MODE
|
||||||
|
|
||||||
# Stage 2: Serve the application with Nginx
|
FROM caddy:2-alpine
|
||||||
FROM nginx:alpine
|
|
||||||
|
|
||||||
# Copy custom Nginx configuration
|
COPY --from=builder /app/dist /srv
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
# Copy the build output to Nginx's html directory
|
EXPOSE 80 443
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Expose port 80
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
EXPOSE 80
|
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|||||||
2
infra/development/hosts.ini
Normal file
2
infra/development/hosts.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[development]
|
||||||
|
development ansible_host=46.224.220.37 ansible_port=4484 ansible_user=root
|
||||||
13
infra/development/site/compose.yml
Normal file
13
infra/development/site/compose.yml
Normal file
@@ -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
|
||||||
51
infra/development/site/deploy-playbook.yml
Normal file
51
infra/development/site/deploy-playbook.yml
Normal file
@@ -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
|
||||||
2
infra/production/hosts.ini
Normal file
2
infra/production/hosts.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[production]
|
||||||
|
production ansible_host=91.107.213.195 ansible_port=22 ansible_user=root
|
||||||
13
infra/production/site/compose.yml
Normal file
13
infra/production/site/compose.yml
Normal file
@@ -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
|
||||||
51
infra/production/site/deploy-playbook.yml
Normal file
51
infra/production/site/deploy-playbook.yml
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user