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: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
|
||||
|
||||
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