--- 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 ```dockerfile # 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"] ``` ```dockerfile # 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 ```yaml # 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 ```yaml # .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 ```yaml # .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 ```nginx # /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 ```javascript // 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 ```javascript // 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 ```javascript 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 ```bash #!/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 ```bash # .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 ```bash #!/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 ```