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