Add full-stack Hello World app with CI/CD

- Go backend with PostgreSQL connection
- Next.js frontend with TypeScript
- Docker Compose for local development
- Woodpecker CI pipeline for build and push
- Kubernetes manifests with Kustomize
- ArgoCD application for GitOps deployment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Erdenebat Tsenddorj
2026-01-21 21:06:08 +08:00
parent 2c4e7d2c38
commit f311439621
26 changed files with 955 additions and 1 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=hellodb
# Backend
PORT=8080
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:8080

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build
.next/
out/
dist/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Go
backend/main
*.exe
# Logs
*.log
npm-debug.log*

78
.woodpecker.yaml Normal file
View File

@@ -0,0 +1,78 @@
# Woodpecker CI Pipeline
# Go Backend + Next.js Frontend + PostgreSQL
when:
- event: [push, pull_request]
branch: main
steps:
# 1. Backend Docker image build
build-backend:
image: docker:24-dind
privileged: true
commands:
- docker build -t hell-world-backend:${CI_COMMIT_SHA:0:8} ./backend
- docker tag hell-world-backend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8}
- docker tag hell-world-backend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-backend:latest
# 2. Frontend Docker image build
build-frontend:
image: docker:24-dind
privileged: true
commands:
- docker build -t hell-world-frontend:${CI_COMMIT_SHA:0:8} ./frontend
- docker tag hell-world-frontend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8}
- docker tag hell-world-frontend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-frontend:latest
# 3. Push backend to Harbor registry
push-backend:
image: docker:24-dind
privileged: true
commands:
- echo "$HARBOR_PASSWORD" | docker login registry.gecore.mn -u "$HARBOR_USER" --password-stdin
- docker push registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8}
- docker push registry.gecore.mn/library/hell-world-backend:latest
secrets: [harbor_user, harbor_password]
when:
event: push
branch: main
# 4. Push frontend to Harbor registry
push-frontend:
image: docker:24-dind
privileged: true
commands:
- echo "$HARBOR_PASSWORD" | docker login registry.gecore.mn -u "$HARBOR_USER" --password-stdin
- docker push registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8}
- docker push registry.gecore.mn/library/hell-world-frontend:latest
secrets: [harbor_user, harbor_password]
when:
event: push
branch: main
# 5. Update Kubernetes manifests with new image tags
update-manifests:
image: alpine:latest
commands:
- apk add --no-cache sed git
- sed -i "s|image:.*hell-world-backend.*|image: registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8}|g" manifests/backend-deployment.yaml
- sed -i "s|image:.*hell-world-frontend.*|image: registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8}|g" manifests/frontend-deployment.yaml
- git config user.email "ci@gecore.mn"
- git config user.name "Woodpecker CI"
- git add manifests/
- git commit -m "ci: update images to ${CI_COMMIT_SHA:0:8}" || true
- git push origin main || true
when:
event: push
branch: main
# 6. Deploy notification
notify:
image: alpine:latest
commands:
- echo "✅ Build completed for commit ${CI_COMMIT_SHA:0:8}"
- echo "📦 Backend: registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8}"
- echo "📦 Frontend: registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8}"
- echo "🔗 ArgoCD will sync automatically"
when:
status: [success, failure]

126
README.md
View File

@@ -1,2 +1,126 @@
# hell-world # Hello World - Go + PostgreSQL + Next.js
Full-stack Hello World application using Go backend, PostgreSQL database, and Next.js frontend.
## Project Structure
```
.
├── backend/ # Go API server
│ ├── main.go
│ ├── go.mod
│ └── Dockerfile
├── frontend/ # Next.js application
│ ├── app/
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml
└── README.md
```
## Quick Start with Docker
```bash
docker-compose up --build
```
Services:
- Frontend: http://localhost:3000
- Backend API: http://localhost:8080
- PostgreSQL: localhost:5432
## Manual Setup
### Backend (Go)
```bash
cd backend
go mod tidy
DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=hellodb go run main.go
```
### Frontend (Next.js)
```bash
cd frontend
npm install
NEXT_PUBLIC_API_URL=http://localhost:8080 npm run dev
```
### PostgreSQL
```bash
docker run -d \
--name hello-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=hellodb \
-p 5432:5432 \
postgres:16-alpine
```
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/hello` | GET | Returns hello message and DB status |
| `/api/health` | GET | Health check |
## Tech Stack
- **Backend**: Go 1.21
- **Database**: PostgreSQL 16
- **Frontend**: Next.js 14, React 18, TypeScript
## CI/CD Pipeline
### Woodpecker CI
`.woodpecker.yaml` файл нь дараах алхмуудыг гүйцэтгэнэ:
1. **build-backend** - Go backend Docker image build
2. **build-frontend** - Next.js frontend Docker image build
3. **push-backend** - Harbor registry рүү backend push
4. **push-frontend** - Harbor registry рүү frontend push
5. **update-manifests** - Kubernetes manifest дахь image tag шинэчлэх
6. **notify** - Deployment notification
### ArgoCD (GitOps)
ArgoCD application тохируулах:
```bash
kubectl apply -f argocd-application.yaml -n argocd
```
ArgoCD нь `manifests/` directory-г автоматаар sync хийнэ.
## Kubernetes Deployment
### Manifests
```
manifests/
├── namespace.yaml # hell-world namespace
├── postgres-secret.yaml # Database credentials
├── postgres-pvc.yaml # PostgreSQL storage
├── postgres-deployment.yaml # PostgreSQL deployment
├── postgres-service.yaml # PostgreSQL service
├── backend-deployment.yaml # Go backend deployment
├── backend-service.yaml # Backend service
├── frontend-deployment.yaml # Next.js frontend deployment
├── frontend-service.yaml # Frontend service
├── ingress.yaml # Ingress with TLS
└── kustomization.yaml # Kustomize config
```
### Manual Deploy
```bash
kubectl apply -k manifests/
```
### URLs (Production)
- Frontend: https://hell-world.gecore.mn
- Backend API: https://hell-world-api.gecore.mn

35
argocd-application.yaml Normal file
View File

@@ -0,0 +1,35 @@
# ArgoCD Application - GitOps deployment
# kubectl apply -f argocd-application.yaml -n argocd
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: hell-world
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
# Gitea repository URL
repoURL: https://git.gecore.mn/admin/hell-world.git
targetRevision: HEAD
path: manifests
destination:
server: https://kubernetes.default.svc
namespace: hell-world
syncPolicy:
automated:
prune: true # Устгагдсан resource-уудыг автоматаар устгана
selfHeal: true # Гараар өөрчилсөн зүйлсийг автоматаар сэргээнэ
syncOptions:
- CreateNamespace=true
- PruneLast=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m

19
backend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o main .
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]

5
backend/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module hello-world-backend
go 1.21
require github.com/lib/pq v1.10.9

93
backend/main.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
_ "github.com/lib/pq"
)
type Response struct {
Message string `json:"message"`
DBStatus string `json:"db_status"`
}
var db *sql.DB
func main() {
// PostgreSQL connection
dbHost := getEnv("DB_HOST", "localhost")
dbPort := getEnv("DB_PORT", "5432")
dbUser := getEnv("DB_USER", "postgres")
dbPassword := getEnv("DB_PASSWORD", "postgres")
dbName := getEnv("DB_NAME", "hellodb")
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, dbUser, dbPassword, dbName)
var err error
db, err = sql.Open("postgres", connStr)
if err != nil {
log.Printf("Warning: Could not connect to database: %v", err)
} else {
defer db.Close()
}
// HTTP handlers
http.HandleFunc("/api/hello", corsMiddleware(helloHandler))
http.HandleFunc("/api/health", corsMiddleware(healthHandler))
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func helloHandler(w http.ResponseWriter, r *http.Request) {
dbStatus := "disconnected"
if db != nil {
err := db.Ping()
if err == nil {
dbStatus = "connected"
}
}
response := Response{
Message: "Hello World! - Go + PostgreSQL + Next.js",
DBStatus: dbStatus,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

46
docker-compose.yml Normal file
View File

@@ -0,0 +1,46 @@
services:
postgres:
image: postgres:16-alpine
container_name: hello-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: hellodb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
backend:
build: ./backend
container_name: hello-backend
ports:
- "8080:8080"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: hellodb
PORT: 8080
depends_on:
postgres:
condition: service_healthy
frontend:
build: ./frontend
container_name: hello-frontend
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: http://localhost:8080
depends_on:
- backend
volumes:
postgres_data:

23
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

18
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Hello World App',
description: 'Go + PostgreSQL + Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

118
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,118 @@
'use client'
import { useEffect, useState } from 'react'
interface ApiResponse {
message: string
db_status: string
}
export default function Home() {
const [data, setData] = useState<ApiResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'
fetch(`${apiUrl}/api/hello`)
.then((res) => res.json())
.then((data) => {
setData(data)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
return (
<main style={styles.main}>
<div style={styles.container}>
<h1 style={styles.title}>Hello World!</h1>
<p style={styles.subtitle}>Go + PostgreSQL + Next.js</p>
<div style={styles.card}>
{loading && <p>Loading...</p>}
{error && <p style={styles.error}>Error: {error}</p>}
{data && (
<>
<p style={styles.message}>{data.message}</p>
<p style={styles.status}>
Database:
<span style={{
color: data.db_status === 'connected' ? '#22c55e' : '#ef4444',
marginLeft: '8px'
}}>
{data.db_status}
</span>
</p>
</>
)}
</div>
<div style={styles.techStack}>
<span style={styles.badge}>Go</span>
<span style={styles.badge}>PostgreSQL</span>
<span style={styles.badge}>Next.js</span>
</div>
</div>
</main>
)
}
const styles: { [key: string]: React.CSSProperties } = {
main: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
fontFamily: 'system-ui, -apple-system, sans-serif',
},
container: {
textAlign: 'center',
color: 'white',
},
title: {
fontSize: '4rem',
marginBottom: '0.5rem',
textShadow: '2px 2px 4px rgba(0,0,0,0.2)',
},
subtitle: {
fontSize: '1.25rem',
opacity: 0.9,
marginBottom: '2rem',
},
card: {
background: 'rgba(255,255,255,0.95)',
borderRadius: '12px',
padding: '2rem',
color: '#333',
boxShadow: '0 10px 40px rgba(0,0,0,0.2)',
marginBottom: '2rem',
},
message: {
fontSize: '1.25rem',
fontWeight: 500,
},
status: {
marginTop: '1rem',
fontSize: '1rem',
},
error: {
color: '#ef4444',
},
techStack: {
display: 'flex',
gap: '1rem',
justifyContent: 'center',
},
badge: {
background: 'rgba(255,255,255,0.2)',
padding: '0.5rem 1rem',
borderRadius: '20px',
fontSize: '0.875rem',
},
}

6
frontend/next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "hello-world-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"typescript": "^5.3.0"
}
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: hell-world
labels:
app: backend
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: registry.gecore.mn/library/hell-world-backend:latest
ports:
- containerPort: 8080
name: http
env:
- name: DB_HOST
value: postgres
- name: DB_PORT
value: "5432"
- name: DB_USER
valueFrom:
secretKeyRef:
name: postgres-secret
key: POSTGRES_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: POSTGRES_PASSWORD
- name: DB_NAME
valueFrom:
secretKeyRef:
name: postgres-secret
key: POSTGRES_DB
- name: PORT
value: "8080"
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
livenessProbe:
httpGet:
path: /api/health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /api/health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: hell-world
labels:
app: backend
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
protocol: TCP
name: http
selector:
app: backend

View File

@@ -0,0 +1,45 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: hell-world
labels:
app: frontend
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: registry.gecore.mn/library/hell-world-frontend:latest
ports:
- containerPort: 3000
name: http
env:
- name: NEXT_PUBLIC_API_URL
value: "https://hell-world-api.gecore.mn"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 5

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: hell-world
labels:
app: frontend
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
protocol: TCP
name: http
selector:
app: frontend

38
manifests/ingress.yaml Normal file
View File

@@ -0,0 +1,38 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hell-world
namespace: hell-world
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- hell-world.gecore.mn
- hell-world-api.gecore.mn
secretName: hell-world-tls
rules:
# Frontend
- host: hell-world.gecore.mn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 3000
# Backend API
- host: hell-world-api.gecore.mn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backend
port:
number: 8080

View File

@@ -0,0 +1,20 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: hell-world
resources:
- namespace.yaml
- postgres-secret.yaml
- postgres-pvc.yaml
- postgres-deployment.yaml
- postgres-service.yaml
- backend-deployment.yaml
- backend-service.yaml
- frontend-deployment.yaml
- frontend-service.yaml
- ingress.yaml
commonLabels:
app.kubernetes.io/name: hell-world
app.kubernetes.io/managed-by: kustomize

6
manifests/namespace.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: hell-world
labels:
app: hell-world

View File

@@ -0,0 +1,56 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: hell-world
labels:
app: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
envFrom:
- secretRef:
name: postgres-secret
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
livenessProbe:
exec:
command:
- pg_isready
- -U
- postgres
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- postgres
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc

View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: hell-world
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: local-path

View File

@@ -0,0 +1,10 @@
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
namespace: hell-world
type: Opaque
stringData:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: hellodb

View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: hell-world
labels:
app: postgres
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
protocol: TCP
name: postgres
selector:
app: postgres