initial commit

This commit is contained in:
2026-01-26 14:19:24 +01:00
commit f332d62f8a
8 changed files with 538 additions and 0 deletions

169
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,169 @@
name: CI
on:
push:
branches:
- main
pull_request:
env:
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
REGISTRY_LOCATION: ${{ vars.REGISTRY_LOCATION }}
PORT: ${{ vars.PORT }}
TEST_PORT: ${{ vars.TEST_PORT }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
PROD_SERVER_HOST: ${{ secrets.PROD_SERVER_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
lint:
name: ✅ Lint & Validate
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout repository
uses: actions/checkout@v4
- name: 🔧 Validate HTML
run: |
echo "Basic HTML validation"
test -f index.html
- name: 🐋 Validate Dockerfile
run: |
dockerfilelint Dockerfile || true
build:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: lint
steps:
- name: 📥 Checkout repository
uses: actions/checkout@v4
- name: 🔖 Set image metadata
run: |
echo "RUN_NUMBER=${GITEA_RUN_NUMBER}" >> $GITEA_ENV
- name: 🐋 Build Docker image
run: |
docker build \
-t $IMAGE_NAME:$RUN_NUMBER \
-t $IMAGE_NAME:latest \
.
- name: Image info
run: docker images | grep $IMAGE_NAME
- name: 🔑 Login to registry
run: |
echo "$REGISTRY_TOKEN" | docker login $REGISTRY_LOCATION \
-u "$REGISTRY_USER" --password-stdin
- name: 🏷️ Build images
run: |
docker tag $IMAGE_NAME:latest $REGISTRY_LOCATION/$IMAGE_NAME:latest
docker tag $IMAGE_NAME:$RUN_NUMBER $REGISTRY_LOCATION/$IMAGE_NAME:build-$RUN_NUMBER
- name: 🚀 Push images
run: |
docker push $REGISTRY_LOCATION/$IMAGE_NAME:latest
docker push $REGISTRY_LOCATION/$IMAGE_NAME:build-$RUN_NUMBER
deploy:
name: 🚀 Deploy image to prod
runs-on: ubuntu-latest
needs: build
steps:
- name: 🔑 Setup SSH
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H $PROD_SERVER_HOST >> ~/.ssh/known_hosts
- name: 🔑 Login to registry
run: |
echo "$REGISTRY_TOKEN" | docker login $REGISTRY_LOCATION \
-u "$REGISTRY_USER" --password-stdin
- name: 🚢 Deploy container
run: |
ssh $DEPLOY_USER@$PROD_SERVER_HOST << 'EOF'
set -e
NEW_IMAGE_NAME="$REGISTRY_LOCATION/$IMAGE_NAME:build-$RUN_NUMBER"
OLD_CONTAINER_NAME=$(docker inspect $IMAGE_NAME --format='{{.Config.Image}}')
NEW_CONTAINER_IMAGE="$IMAGE_NAME:build-$RUN_NUMBER"
echo "Pulling image $NEW_IMAGE_NAME"
docker pull "$NEW_IMAGE_NAME"
echo "Starting new test container"
docker run -d \
--name $NEW_CONTAINER_IMAGE \
--health-cmd="wget -qO- http://localhost:$TEST_PORT/health || exit 1" \
--health-interval=5s \
--health-retries=5 \
--health-timeout=2s \
--health-start-period=5s \
-p $TEST_PORT:80 \
"$NEW_CONTAINER_IMAGE"
echo "Waiting for healthcheck..."
for i in {1..15}; do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' $NEW_CONTAINER_IMAGE)
[ "$STATUS" = "healthy" ] && break
[ "$STATUS" = "unhealthy" ] && break
sleep 2
done
docker stop $NEW_CONTAINER_IMAGE
docker image rm $NEW_CONTAINER_IMAGE
if [ "$STATUS" = "healthy" ]; then
echo "✅ Container is healthy - starting deployment"
docker stop $OLD_CONTAINER_NAME
docker run -d \
--name $NEW_CONTAINER_IMAGE \
--health-cmd="wget -qO- http://localhost:$PORT/health || exit 1" \
--health-interval=5s \
--health-retries=5 \
--health-timeout=2s \
--health-start-period=5s \
-p $PORT:80 \
"$NEW_CONTAINER_IMAGE"
sleep 10
DEPLOYMENT_STATUS=$(docker inspect --format='{{.State.Health.Status}}' $NEW_CONTAINER_IMAGE)
if [ "$DEPLOYMENT_STATUS" != "healthy" ]; then
echo "❌ Deployment failed — rolling back"
docker logs $NEW_CONTAINER_IMAGE
docker rm -f $NEW_CONTAINER_IMAGE
if [ -n "$OLD_CONTAINER_NAME" ]; then
docker run -d \
--name $OLD_CONTAINER_NAME \
--health-cmd="wget -qO- http://localhost:$PORT/health || exit 1" \
--health-interval=5s \
--health-retries=5 \
--health-timeout=2s \
--health-start-period=5s \
-p $PORT:80 \
"$OLD_CONTAINER_NAME"
echo "❌ Deployment failed — ✅ rollback successfull"
fi
exit 1
fi
docker image rm $OLD_CONTAINER_NAME
echo "✅ Deployment successfull"
else
echo "❌ Deployment failed — ❤️ healthcheck failed on the test container"
fi
EOF

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM nginx:1.29-alpine
# Remove default config
RUN rm /etc/nginx/conf.d/default.conf
# Copy nginx main config
COPY docker/nginx.conf /etc/nginx/nginx.conf
# Copy virtual host template
COPY docker/default.conf.template /etc/nginx/templates/default.conf.template
# Copy static website
COPY . /usr/share/nginx/html
# Expose HTTP port
EXPOSE 80
# Nginx supports envsubst natively in /etc/nginx/templates
CMD ["nginx", "-g", "daemon off;"]

111
README.md Normal file
View File

@@ -0,0 +1,111 @@
# 🚀 Showcase Website CI/CD Docker Template
This repository is a **production-ready template** for deploying a static website using:
- Plain **HTML / CSS / JavaScript**
- **Docker + Nginx**
- **Gitea Actions** for CI/CD
- Automated **build numbering**
- Safe **deployment with healthchecks & rollback**
It is designed to be **forked and reused** as a starting point for real-world deployments.
## 📦 Whats included
- ✅ Simple showcase website
- ✅ Configurable Nginx container
- ✅ Dockerfile ready for production
- ✅ CI pipeline (build + tag + push)
- ✅ CD pipeline (deploy + healthcheck)
- ✅ Automatic rollback on failed deployment
## 📁 Repository structure
```
.
├── .gitea/
│ └── workflows/
│ └── ci.yml
├── assets/
├── docker/
│ ├── default.conf.template
│ └── nginx.conf
├── src/
│ ├── css/
│ └── js/
├── Dockerfile
├── index.html
└── README.md
```
## 🐳 Docker image
### Image tags
Each build produces multiple tags:
| Tag | Description |
| ----------- | ----------- |
| `build-N` | Incremental build number with N as the build number (recommended for deployments) |
| `latest` | Pointer to most recent build |
## 🔁 CI/CD Pipeline Overview
### CI (Build)
Triggered on push to `main`:
1. Logs into the Docker registry
2. Build Docker image
3. Tag with: `build-N` & `latest`
4. Push image to registry
### CD (Deploy)
The deploy job:
1. Connects to the server via SSH
2. Logs into the Docker registry
3. Pulls the new `build-N` image
4. Starts the new `build-N` container on a test port `81`
5. Runs healthchecks
6. **If unhealthy**
1. Stops the new `build-N` container
2. Exits the CD process (keeping the old container running)
7. **If healthy → stop the new `build-N` container and stop here**
1. Stops the new `build-N` container
2. Stops the new old container
3. Starts the new `build-N` container on the correct port `80`
4. Remove the docker image of the old container
This guarantees:
- No broken deployments
- Clear CI failure signal
- Easy rollback
## ❤️ Healthcheck
The container exposes:
```
GET /health
```
Expected response:
```
200 OK
ok
```
Used by:
- Docker healthchecks
- Deployment validation
## 🔐 Required Gitea Secrets & Variables
The following secrets and variables **must be setup** inside the destination repository.
### Variables
| Variable | Description |
| ------------------- | --------------------------------------------------- |
| `IMAGE_NAME` | Name of the docker image (built and deployed) |
| `REGISTRY_LOCATION` | Location of the image registery |
| `PORT` | Port on which the site will be exposed on |
| `TEST_PORT` | Port on which the test container will be exposed on |
### Secrets
| Secrets | Description |
| ------------------ | --------------------------------------------------------------------- |
| `REGISTRY_USER` | User used in order to log into the image registery |
| `REGISTRY_TOKEN` | Token (or password) used in order to log into the image registery |
| `PROD_SERVER_HOST` | Location of the production server host (must be reachable from GITEA) |
| `DEPLOY_USER` | User used in order to ssh into the production server |
| `DEPLOY_SSH_KEY` | SSH private key used in order to ssh into the production server |

14
assets/scripts/main.js Normal file
View File

@@ -0,0 +1,14 @@
document.addEventListener("DOMContentLoaded", () => {
const ctaBtn = document.getElementById("cta-btn");
const form = document.getElementById("contact-form");
ctaBtn.addEventListener("click", () => {
alert("This template is ready to be extended 🚀");
});
form.addEventListener("submit", (e) => {
e.preventDefault();
alert("Form submission placeholder.");
form.reset();
});
});

103
assets/styles/style.css Normal file
View File

@@ -0,0 +1,103 @@
:root {
--primary: #2b2d42;
--accent: #ef233c;
--light: #f8f9fa;
--text: #333;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text);
line-height: 1.6;
background-color: var(--light);
}
.container {
max-width: 1100px;
margin: auto;
padding: 2rem;
}
.navbar {
background: var(--primary);
color: white;
}
.navbar .container {
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar a {
color: white;
margin-left: 1rem;
text-decoration: none;
}
.hero {
background: linear-gradient(135deg, var(--primary), #1a1c2c);
color: white;
text-align: center;
padding: 5rem 2rem;
}
.hero button {
margin-top: 1.5rem;
padding: 0.75rem 2rem;
border: none;
background: var(--accent);
color: white;
font-size: 1rem;
cursor: pointer;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.feature {
background: white;
padding: 2rem;
border-radius: 8px;
}
.about {
background: #edf2f4;
}
.contact form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.contact input,
.contact textarea {
padding: 0.75rem;
font-size: 1rem;
}
.contact button {
align-self: flex-start;
padding: 0.75rem 1.5rem;
background: var(--primary);
color: white;
border: none;
cursor: pointer;
}
.footer {
text-align: center;
padding: 1.5rem;
background: var(--primary);
color: white;
}

View File

@@ -0,0 +1,22 @@
server {
listen ${NGINX_PORT};
server_name ${NGINX_SERVER_NAME};
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

26
docker/nginx.conf Normal file
View File

@@ -0,0 +1,26 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}

74
index.html Normal file
View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Template Showcase</title>
<link rel="stylesheet" href="assets/styles/style.css" />
</head>
<body>
<header class="navbar">
<div class="container">
<h1 class="logo">MyTemplate</h1>
<nav>
<a href="#features">Features</a>
<a href="#about">About</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main>
<section class="hero">
<div class="container">
<h2>Ready-to-Deploy Website Template</h2>
<p>
A simple, fast, and Docker-ready showcase website.
</p>
<button id="cta-btn">Get Started</button>
</div>
</section>
<section id="features" class="features container">
<div class="feature">
<h3>Simple</h3>
<p>Plain HTML, CSS, and JS. No framework lock-in.</p>
</div>
<div class="feature">
<h3>Fast</h3>
<p>Optimized for performance and easy deployment.</p>
</div>
<div class="feature">
<h3>Deployable</h3>
<p>Designed to be containerized and shipped anywhere.</p>
</div>
</section>
<section id="about" class="about">
<div class="container">
<h2>About</h2>
<p>
This repository serves as a starting point for production-ready
websites with CI/CD, Docker, and server deployment.
</p>
</div>
</section>
<section id="contact" class="contact container">
<h2>Contact</h2>
<form id="contact-form">
<input type="text" placeholder="Your name" required />
<input type="email" placeholder="Your email" required />
<textarea placeholder="Message"></textarea>
<button type="submit">Send</button>
</form>
</section>
</main>
<footer class="footer">
<p>© 2026 MyTemplate. All rights reserved.</p>
</footer>
<script src="assets/scripts/main.js"></script>
</body>
</html>