| name | devops |
| description | DevOps practices for web development including Docker, CI/CD, deployment, monitoring, and infrastructure as code. Use when setting up deployment pipelines, containerizing applications, configuring servers, or implementing DevOps workflows. |
DevOps for Web Development
Containerization, CI/CD, deployment, and infrastructure practices.
Docker
Dockerfile Best Practices
# Multi-stage build for Node.js
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine AS runner
WORKDIR /app
# Run as non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
COPY --from=builder /app/node_modules ./node_modules
COPY --chown=nextjs:nodejs . .
USER nextjs
EXPOSE 3000
ENV NODE_ENV production
CMD ["node", "server.js"]
# PHP/Laravel
FROM php:8.2-fpm-alpine AS base
RUN apk add --no-cache \
libzip-dev \
libpng-dev \
oniguruma-dev \
&& docker-php-ext-install \
pdo_mysql \
zip \
gd \
mbstring \
opcache
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Production stage
FROM base AS production
COPY . .
RUN composer install --no-dev --optimize-autoloader
RUN php artisan config:cache && \
php artisan route:cache && \
php artisan view:cache
# Development stage
FROM base AS development
RUN apk add --no-cache $PHPIZE_DEPS && \
pecl install xdebug && \
docker-php-ext-enable xdebug
Docker Compose
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
target: development
volumes:
- .:/var/www/html
- /var/www/html/vendor
ports:
- "8000:8000"
environment:
- APP_ENV=local
- DB_HOST=db
- REDIS_HOST=redis
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: mysql:8.0
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: app
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- .:/var/www/html
depends_on:
- app
volumes:
db_data:
redis_data:
CI/CD
GitHub Actions
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: test
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm test -- --coverage
env:
DATABASE_URL: mysql://root:secret@localhost:3306/test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
build:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/app
docker compose pull
docker compose up -d
docker system prune -f
GitLab CI
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
DOCKER_TLS_CERTDIR: "/certs"
test:
stage: test
image: node:20-alpine
services:
- mysql:8.0
variables:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: test
script:
- npm ci
- npm run lint
- npm test
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
only:
- main
deploy:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
- ssh -o StrictHostKeyChecking=no $SERVER_USER@$SERVER_HOST "
cd /var/www/app &&
docker compose pull &&
docker compose up -d"
only:
- main
environment:
name: production
url: https://example.com
Nginx Configuration
# /etc/nginx/sites-available/app.conf
upstream app {
server app:3000;
keepalive 64;
}
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
root /var/www/html/public;
index index.html index.php;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
# Static files
location /static/ {
alias /var/www/html/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy
location /api/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# PHP-FPM (Laravel)
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}
Monitoring
Health Checks
// Express health check endpoint
app.get('/health', async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
database: 'unknown',
redis: 'unknown',
};
try {
await db.query('SELECT 1');
checks.database = 'healthy';
} catch (e) {
checks.database = 'unhealthy';
}
try {
await redis.ping();
checks.redis = 'healthy';
} catch (e) {
checks.redis = 'unhealthy';
}
const isHealthy = checks.database === 'healthy' && checks.redis === 'healthy';
res.status(isHealthy ? 200 : 503).json(checks);
});
Logging
// Winston logger setup
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'api' },
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
new winston.transports.File({
filename: 'logs/combined.log',
}),
],
});
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info('Request completed', {
method: req.method,
url: req.url,
status: res.statusCode,
duration: Date.now() - start,
ip: req.ip,
});
});
next();
});
Prometheus Metrics
import client from 'prom-client';
// Enable default metrics
client.collectDefaultMetrics();
// Custom metrics
const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.1, 0.5, 1, 2, 5],
});
// Middleware
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on('finish', () => {
end({ method: req.method, route: req.route?.path || 'unknown', status: res.statusCode });
});
next();
});
// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', client.register.contentType);
res.end(await client.register.metrics());
});
Server Setup
Ubuntu Server Hardening
#!/bin/bash
# Update system
apt update && apt upgrade -y
# Create deploy user
adduser deploy
usermod -aG sudo deploy
usermod -aG docker deploy
# SSH hardening
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd
# Firewall
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow http
ufw allow https
ufw enable
# Install Docker
curl -fsSL https://get.docker.com | sh
# Install fail2ban
apt install fail2ban -y
systemctl enable fail2ban
# Automatic security updates
apt install unattended-upgrades -y
dpkg-reconfigure -plow unattended-upgrades
Environment Management
# .env.production
NODE_ENV=production
DATABASE_URL=mysql://user:pass@db:3306/app
REDIS_URL=redis://redis:6379
SECRET_KEY=${SECRET_KEY} # From secrets manager
# docker-compose.prod.yml
version: '3.8'
services:
app:
image: ghcr.io/org/app:latest
env_file:
- .env.production
secrets:
- db_password
- api_key
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
secrets:
db_password:
external: true
api_key:
external: true
Backup Strategy
#!/bin/bash
# backup.sh
BACKUP_DIR="/backups"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=30
# Database backup
docker exec db mysqldump -u root -p$MYSQL_ROOT_PASSWORD app | gzip > $BACKUP_DIR/db_$DATE.sql.gz
# Files backup
tar -czf $BACKUP_DIR/files_$DATE.tar.gz /var/www/html/uploads
# Upload to S3
aws s3 cp $BACKUP_DIR/db_$DATE.sql.gz s3://bucket/backups/db/
aws s3 cp $BACKUP_DIR/files_$DATE.tar.gz s3://bucket/backups/files/
# Cleanup old backups
find $BACKUP_DIR -type f -mtime +$RETENTION_DAYS -delete