Skip to content

Commit 8923e14

Browse files
Add progressive delivery and infra scaffolding
1 parent 68195e8 commit 8923e14

12 files changed

Lines changed: 307 additions & 13 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
TRAEFIK_HTTP_PORT=80
2+
TRAEFIK_HTTPS_PORT=443
23
TRAEFIK_DASHBOARD_PORT=8080
34
DOCKER_SOCKET=/var/run/docker.sock
45
DEPLOY_WORKSPACE=/deployments
56
DEPLOY_DOCKER_NETWORK=self-hosted-devops_edge
67
DEPLOY_PROJECT_LABEL=self-hosted-devops
78
DEPLOY_DEFAULT_CONTAINER_PORT=3000
9+
DEPLOY_HEALTH_PATH=/health
10+
DEPLOY_HEALTH_TIMEOUT_SECONDS=60
11+
DEPLOY_IMAGE_REGISTRY=
12+
DEPLOY_PUSH_IMAGES=false
813
DEPLOY_ALLOWED_REPO_PREFIXES=https://github.com/,git@github.com:,file://
914
DEPLOY_TOKEN=change-me
15+
TRAEFIK_ACME_EMAIL=admin@example.com

.github/workflows/ci-cd.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ jobs:
4747
trigger-deployment:
4848
needs: validate-and-build
4949
runs-on: ubuntu-latest
50+
environment:
51+
name: production
52+
url: ${{ vars.DEPLOY_WEBHOOK_URL }}
5053
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.DEPLOY_WEBHOOK_URL != ''
5154
steps:
5255
- name: Trigger deployment API

README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ flowchart LR
3232

3333
- API-driven deployment workflow with `POST /deploy`.
3434
- Docker image builds from Git repositories.
35+
- Blue/green-style candidate validation before replacing the routed container.
36+
- Optional image registry tagging and push before deployment.
3537
- Automatic container replacement by service name.
3638
- Dynamic domain-based routing via Traefik labels.
3739
- Multi-service routing with `api.localhost`, `app.localhost`, and deployed app domains.
@@ -42,6 +44,7 @@ flowchart LR
4244
- Reproducible Docker Compose environment.
4345
- Optional Prometheus and Grafana observability profile.
4446
- Trivy image vulnerability scanning in CI.
47+
- Traefik ACME/Let's Encrypt configuration for production TLS.
4548
- GitHub Actions workflow for CI/CD integration.
4649

4750
## How It Works
@@ -51,9 +54,9 @@ flowchart LR
5154
3. The API validates `repo`, `name`, and `domain`.
5255
4. The API clones the Git repository into a deployment workspace.
5356
5. Docker builds an image from the cloned repository.
54-
6. Any existing container with the same app name is stopped and removed.
55-
7. A new container is started on the Traefik network.
56-
8. Traefik discovers the container through labels and routes traffic to the configured domain.
57+
6. A candidate container is started and health-checked before routing changes.
58+
7. The previous routed container is replaced only after the candidate passes validation.
59+
8. Traefik discovers the new container through labels and routes traffic to the configured domain.
5760

5861
## Getting Started
5962

@@ -272,13 +275,11 @@ GET /metrics
272275

273276
## Future Improvements
274277

275-
- Add blue/green or canary deployments for safer rollouts.
276-
- Push built images to a registry before deployment.
277-
- Add production TLS certificates with Let's Encrypt.
278-
- Add GitHub Actions deployment environments and approval gates.
279-
- Expand Grafana dashboards with latency, error-rate, and deployment-failure panels.
280-
- Migrate runtime deployments from direct Docker containers to Kubernetes Deployments and Services.
281-
- Expand Terraform from project scaffolding into full host, DNS, firewall, and monitoring provisioning.
278+
- Add weighted canary deployments with gradual traffic shifting.
279+
- Push deployment metadata and audit events into a dedicated deployment history API.
280+
- Add production-ready secret management with SOPS, Vault, or cloud secret stores.
281+
- Replace direct container runtime deployment with Kubernetes Deployments, Services, and progressive delivery controllers.
282+
- Expand Terraform modules for managed databases, managed Redis, backups, and monitoring alerts.
282283

283284
## CV Impact
284285

api/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ DEPLOY_WORKSPACE=/deployments
1212
DEPLOY_DOCKER_NETWORK=self-hosted-devops_edge
1313
DEPLOY_PROJECT_LABEL=self-hosted-devops
1414
DEPLOY_DEFAULT_CONTAINER_PORT=3000
15+
DEPLOY_HEALTH_PATH=/health
16+
DEPLOY_HEALTH_TIMEOUT_SECONDS=60
17+
DEPLOY_IMAGE_REGISTRY=
18+
DEPLOY_PUSH_IMAGES=false
1519
DEPLOY_ALLOWED_REPO_PREFIXES=https://github.com/,git@github.com:,file://
1620
DEPLOY_TOKEN=local-dev-token
1721
DOCKER_HOST=tcp://docker-socket-proxy:2375

api/src/config/config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ function loadConfig() {
4747
dockerNetwork: process.env.DEPLOY_DOCKER_NETWORK || "self-hosted-devops_edge",
4848
projectLabel: process.env.DEPLOY_PROJECT_LABEL || "self-hosted-devops",
4949
defaultContainerPort: numberFromEnv("DEPLOY_DEFAULT_CONTAINER_PORT", 3000),
50+
healthPath: process.env.DEPLOY_HEALTH_PATH || "/health",
51+
healthTimeoutSeconds: numberFromEnv("DEPLOY_HEALTH_TIMEOUT_SECONDS", 60),
52+
imageRegistry: process.env.DEPLOY_IMAGE_REGISTRY || "",
53+
pushImages: process.env.DEPLOY_PUSH_IMAGES === "true",
5054
allowedRepoPrefixes: listFromEnv("DEPLOY_ALLOWED_REPO_PREFIXES", [
5155
"https://github.com/",
5256
"git@github.com:",

api/src/services/deploy.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ function requireRepo(value, allowedPrefixes) {
4545
}
4646

4747
function createDeployService({ config, store, git, docker, log }) {
48+
function imageName(serviceName, shortId) {
49+
const localName = `self-hosted-devops/${serviceName}:${shortId}`;
50+
if (!config.deploy.imageRegistry) {
51+
return localName;
52+
}
53+
54+
return `${config.deploy.imageRegistry.replace(/\/$/, "")}/${serviceName}:${shortId}`;
55+
}
56+
4857
async function deployService(payload) {
4958
const repositoryUrl = requireRepo(
5059
payload.repo || payload.repositoryUrl,
@@ -58,7 +67,8 @@ function createDeployService({ config, store, git, docker, log }) {
5867
const deploymentId = crypto.randomUUID();
5968
const shortId = deploymentId.slice(0, 8);
6069
const sourcePath = path.join(config.deploy.workspace, serviceName, shortId);
61-
const image = `self-hosted-devops/${serviceName}:${shortId}`;
70+
const image = imageName(serviceName, shortId);
71+
const candidateName = `candidate-${serviceName}-${shortId}`;
6272
const containerName = `deployed-${serviceName}`;
6373

6474
const deployment = await store.insertDeployment({
@@ -79,11 +89,39 @@ function createDeployService({ config, store, git, docker, log }) {
7989
await store.updateDeployment(deployment.id, { status: "building" });
8090
await docker.buildImage(image, sourcePath, deployment.id);
8191

92+
if (config.deploy.pushImages) {
93+
await store.updateDeployment(deployment.id, { status: "pushing" });
94+
await docker.pushImage(image, deployment.id);
95+
}
96+
97+
await store.updateDeployment(deployment.id, { status: "validating" });
98+
await docker.removeContainer(candidateName, deployment.id);
99+
await docker.runContainer({
100+
name: candidateName,
101+
image,
102+
containerPort,
103+
deploymentId: deployment.id,
104+
labels: [
105+
"traefik.enable=false",
106+
`platform.service=${serviceName}`,
107+
`platform.deployment=${deployment.id}`,
108+
"platform.candidate=true",
109+
],
110+
});
111+
await docker.waitForHttpHealth(
112+
candidateName,
113+
containerPort,
114+
payload.healthPath || config.deploy.healthPath,
115+
config.deploy.healthTimeoutSeconds,
116+
deployment.id,
117+
);
118+
82119
await store.updateDeployment(deployment.id, {
83-
status: "replacing",
120+
status: "switching",
84121
previousContainerName: containerName,
85122
});
86123
await docker.removeContainer(containerName, deployment.id);
124+
await docker.removeContainer(candidateName, deployment.id);
87125

88126
await docker.runContainer({
89127
name: containerName,
@@ -108,6 +146,7 @@ function createDeployService({ config, store, git, docker, log }) {
108146
error: error.message,
109147
});
110148

149+
await docker.removeContainer(candidateName, deployment.id);
111150
await docker.removeContainer(containerName, deployment.id);
112151
await store.updateDeployment(deployment.id, {
113152
status: "failed",

api/src/services/docker.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const http = require("http");
12
const { DockerBuildError, ContainerRuntimeError } = require("../errors/app-error");
23

34
function createDockerService(config, run, log) {
@@ -29,6 +30,17 @@ function createDockerService(config, run, log) {
2930
}
3031
}
3132

33+
async function pushImage(image, deploymentId) {
34+
try {
35+
await run("docker", ["push", image], { deploymentId });
36+
} catch (error) {
37+
throw new DockerBuildError("Docker image push failed", {
38+
image,
39+
stderr: error.stderr,
40+
});
41+
}
42+
}
43+
3244
async function removeContainer(name, deploymentId) {
3345
await run("docker", ["rm", "-f", name], { deploymentId }).catch(() => {});
3446
}
@@ -54,7 +66,73 @@ function createDockerService(config, run, log) {
5466
}
5567
}
5668

57-
return { dockerLabels, buildImage, removeContainer, runContainer };
69+
async function inspectContainerIp(containerName, deploymentId) {
70+
const { stdout } = await run(
71+
"docker",
72+
["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", containerName],
73+
{ deploymentId },
74+
);
75+
76+
return stdout.trim();
77+
}
78+
79+
function requestHealth(host, port, healthPath) {
80+
return new Promise((resolve, reject) => {
81+
const request = http.get(
82+
{
83+
host,
84+
port,
85+
path: healthPath,
86+
timeout: 2000,
87+
},
88+
(response) => {
89+
response.resume();
90+
if (response.statusCode >= 200 && response.statusCode < 400) {
91+
resolve();
92+
return;
93+
}
94+
95+
reject(new Error(`health check returned ${response.statusCode}`));
96+
},
97+
);
98+
99+
request.on("error", reject);
100+
request.on("timeout", () => {
101+
request.destroy(new Error("health check timed out"));
102+
});
103+
});
104+
}
105+
106+
async function waitForHttpHealth(containerName, containerPort, healthPath, timeoutSeconds, deploymentId) {
107+
const deadline = Date.now() + timeoutSeconds * 1000;
108+
let lastError = new Error("health check did not run");
109+
110+
while (Date.now() < deadline) {
111+
try {
112+
const ip = await inspectContainerIp(containerName, deploymentId);
113+
await requestHealth(ip, containerPort, healthPath);
114+
return;
115+
} catch (error) {
116+
lastError = error;
117+
await new Promise((resolve) => setTimeout(resolve, 2000));
118+
}
119+
}
120+
121+
throw new ContainerRuntimeError("Container health check failed", {
122+
containerName,
123+
healthPath,
124+
error: lastError.message,
125+
});
126+
}
127+
128+
return {
129+
dockerLabels,
130+
buildImage,
131+
pushImage,
132+
removeContainer,
133+
runContainer,
134+
waitForHttpHealth,
135+
};
58136
}
59137

60138
module.exports = { createDockerService };

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@ services:
88
- "--api.dashboard=true"
99
- "--api.insecure=true"
1010
- "--entrypoints.web.address=:80"
11+
- "--entrypoints.websecure.address=:443"
12+
- "--certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL:-admin@example.com}"
13+
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
14+
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
15+
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
1116
- "--metrics.prometheus=true"
1217
- "--providers.docker=true"
1318
- "--providers.docker.constraints=Label(`com.docker.compose.project`,`self-hosted-devops`)"
1419
- "--providers.docker.endpoint=tcp://docker-socket-proxy:2375"
1520
- "--providers.docker.exposedbydefault=false"
1621
ports:
1722
- "${TRAEFIK_HTTP_PORT:-80}:80"
23+
- "${TRAEFIK_HTTPS_PORT:-443}:443"
1824
- "${TRAEFIK_DASHBOARD_PORT:-8080}:8080"
25+
volumes:
26+
- traefik-acme:/letsencrypt
1927
depends_on:
2028
- docker-socket-proxy
2129
networks:
@@ -164,3 +172,4 @@ volumes:
164172
prometheus-data:
165173
grafana-data:
166174
deploy-workspace:
175+
traefik-acme:

grafana/dashboards/devops-platform.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,50 @@
4747
],
4848
"title": "Deployments",
4949
"type": "timeseries"
50+
},
51+
{
52+
"datasource": "Prometheus",
53+
"fieldConfig": {
54+
"defaults": {},
55+
"overrides": []
56+
},
57+
"gridPos": {
58+
"h": 8,
59+
"w": 12,
60+
"x": 0,
61+
"y": 8
62+
},
63+
"id": 3,
64+
"targets": [
65+
{
66+
"expr": "devops_api_uptime_seconds",
67+
"refId": "A"
68+
}
69+
],
70+
"title": "API Uptime",
71+
"type": "stat"
72+
},
73+
{
74+
"datasource": "Prometheus",
75+
"fieldConfig": {
76+
"defaults": {},
77+
"overrides": []
78+
},
79+
"gridPos": {
80+
"h": 8,
81+
"w": 12,
82+
"x": 12,
83+
"y": 8
84+
},
85+
"id": 4,
86+
"targets": [
87+
{
88+
"expr": "sum(rate(traefik_service_requests_total{code=~\"5..\"}[5m]))",
89+
"refId": "A"
90+
}
91+
],
92+
"title": "Traefik 5xx Error Rate",
93+
"type": "timeseries"
5094
}
5195
],
5296
"schemaVersion": 39,

0 commit comments

Comments
 (0)