Makefiles for Modern Projects: Not Just for C Anymore

Makefiles have been around since 1976. They’re also still the best task runner for most projects. Here’s why, and how to use them effectively in 2026. The Case for Make Every ecosystem has its own task runner: npm scripts, Gradle, Rake, Poetry, Cargo. When your project spans multiple languagesβ€”a Python backend, TypeScript frontend, Go CLI tool, and Terraform infrastructureβ€”you end up with five different ways to run tests. Make provides one interface: ...

February 26, 2026 Β· 6 min Β· 1154 words Β· Rob Washington

Git Hooks for Automation: Enforce Quality Before It Hits the Repo

Git hooks run scripts at key points in your workflow. Use them to catch problems before they become pull requests. Hook Basics Hooks live in .git/hooks/. Each hook is an executable script named after the event. 1 2 3 4 5 6 # List available hooks ls .git/hooks/*.sample # Make a hook active cp .git/hooks/pre-commit.sample .git/hooks/pre-commit chmod +x .git/hooks/pre-commit Common Hooks Hook When Use Case pre-commit Before commit created Lint, format, test prepare-commit-msg After default message Add ticket numbers commit-msg After message entered Validate format pre-push Before push Run full tests post-merge After merge Install dependencies post-checkout After checkout Environment setup Pre-commit Hook Basic Linting 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #!/bin/bash # .git/hooks/pre-commit echo "Running pre-commit checks..." # Get staged files STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM) # Python files PYTHON_FILES=$(echo "$STAGED_FILES" | grep '\.py$') if [ -n "$PYTHON_FILES" ]; then echo "Linting Python files..." ruff check $PYTHON_FILES || exit 1 black --check $PYTHON_FILES || exit 1 fi # JavaScript files JS_FILES=$(echo "$STAGED_FILES" | grep -E '\.(js|ts)$') if [ -n "$JS_FILES" ]; then echo "Linting JavaScript files..." eslint $JS_FILES || exit 1 fi echo "All checks passed!" Prevent Debug Statements 1 2 3 4 5 6 7 8 9 #!/bin/bash # .git/hooks/pre-commit # Check for debug statements if git diff --cached | grep -E '(console\.log|debugger|import pdb|breakpoint\(\))'; then echo "ERROR: Debug statements found!" echo "Remove console.log, debugger, pdb, or breakpoint() before committing." exit 1 fi Check for Secrets 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #!/bin/bash # .git/hooks/pre-commit # Patterns that might be secrets PATTERNS=( 'password\s*=\s*["\047][^"\047]+' 'api_key\s*=\s*["\047][^"\047]+' 'secret\s*=\s*["\047][^"\047]+' 'AWS_SECRET_ACCESS_KEY' 'PRIVATE KEY' ) for pattern in "${PATTERNS[@]}"; do if git diff --cached | grep -iE "$pattern"; then echo "ERROR: Possible secret detected!" echo "Pattern: $pattern" exit 1 fi done Commit Message Hook Enforce Conventional Commits 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #!/bin/bash # .git/hooks/commit-msg COMMIT_MSG_FILE=$1 COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") # Conventional commit pattern PATTERN="^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,50}" if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then echo "ERROR: Invalid commit message format!" echo "" echo "Expected: <type>(<scope>): <description>" echo "Types: feat, fix, docs, style, refactor, test, chore" echo "" echo "Examples:" echo " feat(auth): add OAuth2 support" echo " fix: resolve memory leak in worker" echo "" exit 1 fi Add Ticket Number 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #!/bin/bash # .git/hooks/prepare-commit-msg COMMIT_MSG_FILE=$1 BRANCH_NAME=$(git symbolic-ref --short HEAD) # Extract ticket number from branch (e.g., feature/JIRA-123-description) TICKET=$(echo "$BRANCH_NAME" | grep -oE '[A-Z]+-[0-9]+') if [ -n "$TICKET" ]; then # Prepend ticket number if not already present if ! grep -q "$TICKET" "$COMMIT_MSG_FILE"; then sed -i.bak "1s/^/[$TICKET] /" "$COMMIT_MSG_FILE" fi fi Pre-push Hook Run Tests Before Push 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #!/bin/bash # .git/hooks/pre-push echo "Running tests before push..." # Run test suite npm test if [ $? -ne 0 ]; then echo "ERROR: Tests failed! Push aborted." exit 1 fi # Check coverage threshold npm run coverage COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct') if (( $(echo "$COVERAGE < 80" | bc -l) )); then echo "ERROR: Coverage below 80%! Current: $COVERAGE%" exit 1 fi echo "All checks passed. Pushing..." Prevent Force Push to Main 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #!/bin/bash # .git/hooks/pre-push PROTECTED_BRANCHES="main master" CURRENT_BRANCH=$(git symbolic-ref --short HEAD) # Check if force pushing while read local_ref local_sha remote_ref remote_sha; do if echo "$PROTECTED_BRANCHES" | grep -qw "$CURRENT_BRANCH"; then if [ "$remote_sha" != "0000000000000000000000000000000000000000" ]; then # Check if this is a force push if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then echo "ERROR: Force push to $CURRENT_BRANCH is not allowed!" exit 1 fi fi fi done Post-merge Hook Auto-install Dependencies 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #!/bin/bash # .git/hooks/post-merge CHANGED_FILES=$(git diff-tree -r --name-only ORIG_HEAD HEAD) # Check if package.json changed if echo "$CHANGED_FILES" | grep -q "package.json"; then echo "package.json changed, running npm install..." npm install fi # Check if requirements.txt changed if echo "$CHANGED_FILES" | grep -q "requirements.txt"; then echo "requirements.txt changed, running pip install..." pip install -r requirements.txt fi # Check if migrations added if echo "$CHANGED_FILES" | grep -q "migrations/"; then echo "New migrations detected!" echo "Run: python manage.py migrate" fi Using pre-commit Framework The pre-commit framework manages hooks declaratively. ...

February 25, 2026 Β· 6 min Β· 1136 words Β· Rob Washington

LLM API Integration Patterns: Building Reliable AI-Powered Applications

Integrating LLM APIs into production applications requires more than just making API calls. These patterns address the real challenges: rate limits, token costs, latency, and reliability. Basic Client Setup 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import os from anthropic import Anthropic client = Anthropic( api_key=os.environ.get("ANTHROPIC_API_KEY"), timeout=60.0, max_retries=3, ) def chat(message: str, system: str = None) -> str: """Simple completion with sensible defaults.""" messages = [{"role": "user", "content": message}] response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=1024, system=system or "You are a helpful assistant.", messages=messages, ) return response.content[0].text Retry with Exponential Backoff Built-in retries help, but custom logic handles edge cases: ...

February 25, 2026 Β· 7 min Β· 1291 words Β· Rob Washington

AWS CLI Essentials: Patterns for Daily Operations

The AWS CLI is the fastest path from question to answer. These patterns cover the operations you’ll use daily. Setup and Configuration 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # Configure default profile aws configure # Configure named profile aws configure --profile production # Use specific profile aws --profile production ec2 describe-instances # Or set environment variable export AWS_PROFILE=production # Verify identity aws sts get-caller-identity Multiple Accounts 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # ~/.aws/credentials [default] aws_access_key_id = AKIA... aws_secret_access_key = ... [production] aws_access_key_id = AKIA... aws_secret_access_key = ... # ~/.aws/config [default] region = us-east-1 output = json [profile production] region = us-west-2 output = json EC2 Operations List Instances 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # All instances aws ec2 describe-instances # Just the essentials aws ec2 describe-instances \ --query 'Reservations[].Instances[].[InstanceId,State.Name,InstanceType,PrivateIpAddress,Tags[?Key==`Name`].Value|[0]]' \ --output table # Running instances only aws ec2 describe-instances \ --filters "Name=instance-state-name,Values=running" \ --query 'Reservations[].Instances[].[InstanceId,PrivateIpAddress]' \ --output text # By tag aws ec2 describe-instances \ --filters "Name=tag:Environment,Values=production" # By instance ID aws ec2 describe-instances --instance-ids i-1234567890abcdef0 Start/Stop/Reboot 1 2 3 4 5 6 7 8 9 10 11 # Stop aws ec2 stop-instances --instance-ids i-1234567890abcdef0 # Start aws ec2 start-instances --instance-ids i-1234567890abcdef0 # Reboot aws ec2 reboot-instances --instance-ids i-1234567890abcdef0 # Terminate (careful!) aws ec2 terminate-instances --instance-ids i-1234567890abcdef0 Get Console Output 1 aws ec2 get-console-output --instance-id i-1234567890abcdef0 --output text SSH Key Pairs 1 2 3 4 5 6 7 8 9 # List aws ec2 describe-key-pairs # Create aws ec2 create-key-pair --key-name mykey --query 'KeyMaterial' --output text > mykey.pem chmod 400 mykey.pem # Delete aws ec2 delete-key-pair --key-name mykey S3 Operations List and Navigate 1 2 3 4 5 6 7 8 9 10 11 # List buckets aws s3 ls # List bucket contents aws s3 ls s3://mybucket/ # Recursive listing aws s3 ls s3://mybucket/ --recursive # With human-readable sizes aws s3 ls s3://mybucket/ --recursive --human-readable --summarize Copy and Sync 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Upload file aws s3 cp myfile.txt s3://mybucket/ # Download file aws s3 cp s3://mybucket/myfile.txt ./ # Upload directory aws s3 cp ./mydir s3://mybucket/mydir --recursive # Sync (only changed files) aws s3 sync ./local s3://mybucket/remote # Sync with delete (mirror) aws s3 sync ./local s3://mybucket/remote --delete # Exclude patterns aws s3 sync ./local s3://mybucket/remote --exclude "*.log" --exclude ".git/*" Delete 1 2 3 4 5 6 7 8 # Single file aws s3 rm s3://mybucket/myfile.txt # Directory aws s3 rm s3://mybucket/mydir/ --recursive # Empty bucket aws s3 rm s3://mybucket/ --recursive Presigned URLs 1 2 3 4 5 # Generate download URL (expires in 1 hour) aws s3 presign s3://mybucket/myfile.txt --expires-in 3600 # Upload URL aws s3 presign s3://mybucket/upload.txt --expires-in 3600 Bucket Operations 1 2 3 4 5 6 7 8 # Create bucket aws s3 mb s3://mynewbucket # Delete bucket (must be empty) aws s3 rb s3://mybucket # Force delete (removes contents first) aws s3 rb s3://mybucket --force IAM Operations Users 1 2 3 4 5 6 7 8 9 10 11 # List users aws iam list-users # Create user aws iam create-user --user-name newuser # Delete user aws iam delete-user --user-name olduser # List user's access keys aws iam list-access-keys --user-name myuser Roles 1 2 3 4 5 6 7 8 # List roles aws iam list-roles # Get role details aws iam get-role --role-name MyRole # List attached policies aws iam list-attached-role-policies --role-name MyRole Policies 1 2 3 4 5 6 7 # List policies aws iam list-policies --scope Local # Get policy document aws iam get-policy-version \ --policy-arn arn:aws:iam::123456789012:policy/MyPolicy \ --version-id v1 CloudWatch Logs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # List log groups aws logs describe-log-groups # List log streams aws logs describe-log-streams --log-group-name /aws/lambda/myfunction # Get recent logs aws logs get-log-events \ --log-group-name /aws/lambda/myfunction \ --log-stream-name '2024/01/01/[$LATEST]abc123' \ --limit 100 # Tail logs (requires aws-cli v2) aws logs tail /aws/lambda/myfunction --follow # Filter logs aws logs filter-log-events \ --log-group-name /aws/lambda/myfunction \ --filter-pattern "ERROR" \ --start-time $(date -d '1 hour ago' +%s)000 Lambda 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # List functions aws lambda list-functions # Invoke function aws lambda invoke \ --function-name myfunction \ --payload '{"key": "value"}' \ output.json # Get function config aws lambda get-function-configuration --function-name myfunction # Update function code aws lambda update-function-code \ --function-name myfunction \ --zip-file fileb://function.zip # View recent invocations aws lambda get-function --function-name myfunction RDS 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # List instances aws rds describe-db-instances # Instance details aws rds describe-db-instances --db-instance-identifier mydb \ --query 'DBInstances[0].[DBInstanceIdentifier,DBInstanceStatus,Endpoint.Address]' # Create snapshot aws rds create-db-snapshot \ --db-instance-identifier mydb \ --db-snapshot-identifier mydb-snapshot-$(date +%Y%m%d) # List snapshots aws rds describe-db-snapshots --db-instance-identifier mydb Secrets Manager 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # List secrets aws secretsmanager list-secrets # Get secret value aws secretsmanager get-secret-value --secret-id mysecret \ --query 'SecretString' --output text # Create secret aws secretsmanager create-secret \ --name mysecret \ --secret-string '{"username":"admin","password":"secret"}' # Update secret aws secretsmanager put-secret-value \ --secret-id mysecret \ --secret-string '{"username":"admin","password":"newsecret"}' SSM Parameter Store 1 2 3 4 5 6 7 8 9 10 11 12 # Get parameter aws ssm get-parameter --name /myapp/database/password --with-decryption # Put parameter aws ssm put-parameter \ --name /myapp/database/password \ --value "mysecret" \ --type SecureString \ --overwrite # List parameters by path aws ssm get-parameters-by-path --path /myapp/ --recursive --with-decryption Query and Filter Patterns JMESPath Queries 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # Select specific fields aws ec2 describe-instances \ --query 'Reservations[].Instances[].[InstanceId,State.Name]' # Filter in query aws ec2 describe-instances \ --query 'Reservations[].Instances[?State.Name==`running`].[InstanceId]' # First result only aws ec2 describe-instances \ --query 'Reservations[0].Instances[0].InstanceId' # Flatten nested arrays aws ec2 describe-instances \ --query 'Reservations[].Instances[].Tags[?Key==`Name`].Value[]' Output Formats 1 2 3 4 5 6 7 8 9 10 11 # JSON (default) aws ec2 describe-instances --output json # Table (human readable) aws ec2 describe-instances --output table # Text (tab-separated, good for scripts) aws ec2 describe-instances --output text # YAML aws ec2 describe-instances --output yaml Scripting Patterns Loop Through Resources 1 2 3 4 5 6 7 8 # Stop all instances with specific tag for id in $(aws ec2 describe-instances \ --filters "Name=tag:Environment,Values=dev" \ --query 'Reservations[].Instances[].InstanceId' \ --output text); do echo "Stopping $id" aws ec2 stop-instances --instance-ids "$id" done Wait for State 1 2 3 4 5 6 7 8 # Wait for instance to be running aws ec2 wait instance-running --instance-ids i-1234567890abcdef0 # Wait for instance to stop aws ec2 wait instance-stopped --instance-ids i-1234567890abcdef0 # Wait for snapshot completion aws ec2 wait snapshot-completed --snapshot-ids snap-1234567890abcdef0 Pagination 1 2 3 4 5 6 7 # Auto-pagination (default in CLI v2) aws s3api list-objects-v2 --bucket mybucket # Manual pagination aws s3api list-objects-v2 --bucket mybucket --max-items 100 # Use NextToken from output for next page aws s3api list-objects-v2 --bucket mybucket --starting-token "token..." Useful Aliases 1 2 3 4 5 6 7 8 9 10 # ~/.bashrc or ~/.zshrc # Quick instance list alias ec2ls='aws ec2 describe-instances --query "Reservations[].Instances[].[InstanceId,State.Name,InstanceType,PrivateIpAddress,Tags[?Key==\`Name\`].Value|[0]]" --output table' # Who am I? alias awswho='aws sts get-caller-identity' # S3 bucket sizes alias s3sizes='aws s3 ls | while read _ _ bucket; do aws s3 ls s3://$bucket --recursive --summarize 2>/dev/null | tail -1; echo $bucket; done' Troubleshooting 1 2 3 4 5 6 7 8 9 10 11 # Debug mode aws ec2 describe-instances --debug # Dry run (check permissions without executing) aws ec2 run-instances --dry-run --image-id ami-12345 --instance-type t2.micro # Check CLI version aws --version # Clear credential cache rm -rf ~/.aws/cli/cache/* The AWS CLI rewards muscle memory. Start with the operations you do daily, build aliases for common patterns, and gradually expand. ...

February 25, 2026 Β· 7 min Β· 1350 words Β· Rob Washington

Ansible Playbook Patterns: Idempotent Infrastructure Done Right

Ansible’s simplicity is deceptive. Anyone can write a playbook that works once. Writing playbooks that work reliably, repeatedly, and maintainably requires discipline and patterns. Project Structure a β”œ β”œ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”œ β”‚ β”‚ β”‚ β”œ β”‚ β”‚ β”‚ β”” n ─ ─ ─ ─ ─ s ─ ─ ─ ─ ─ i b a i β”œ β”‚ β”‚ β”‚ β”‚ β”” p β”œ β”œ β”” r β”œ β”œ β”” f β”” l n n ─ ─ l ─ ─ ─ o ─ ─ ─ i ─ e s v ─ ─ a ─ ─ ─ l ─ ─ ─ l ─ / i e y e e b n p β”œ β”” s β”œ β”” b s w d s c n p s s l t r ─ ─ t ─ ─ o i e a / o g o / c e o o ─ ─ a ─ ─ o t b t m i s r . r d g k e s a m n t i c y u h g β”œ β”” i h g s . e b o x g p f / c o r ─ ─ n o r y r a n r t g t s o ─ ─ g s m v s e s i t u / t u l e e s / o s p a w s p r s q n . _ l e . _ s . l y v l b y v . y / m a . s m a y m l r y e l r m l s m r s l / l v / e r s . y m l Inventory Best Practices 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # inventory/production/hosts.yml all: children: webservers: hosts: web1.example.com: web2.example.com: vars: http_port: 80 databases: hosts: db1.example.com: postgres_role: primary db2.example.com: postgres_role: replica loadbalancers: hosts: lb1.example.com: vars: ansible_user: deploy ansible_python_interpreter: /usr/bin/python3 Role Structure r β”œ β”‚ β”œ β”‚ β”œ β”‚ β”‚ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”” o ─ ─ ─ ─ ─ ─ ─ ─ l ─ ─ ─ ─ ─ ─ ─ ─ e s d β”” v β”” t β”œ β”œ β”” h β”” t β”” f β”” m β”” m β”” / e ─ a ─ a ─ ─ ─ a ─ e ─ i ─ e ─ o ─ n f ─ r ─ s ─ ─ ─ n ─ m ─ l ─ t ─ l ─ g a s k d p e a e i u m / m s m i c l m l n s s / m c d n l a a / a n o e a a g / s a u e x t i i i s n r i t i l i l f / s n n n t f s n e n / n e a / . . . a i / . s x . / u y y y l g y / . y l m m m l u m c m t l l l . r l o l / y e n m . f l y . m j l 2 # # # # # # D R E S D T e o n e e e f l t r p s a e r v e t u y i n i l v c d n t a p e e g r o n v i i r c a a n e i r b t s e i l t s a e a b s r l t e ( s h h i a ( g n l h d o e l w r e e r s p s t r i p o r r i i o t r y i ) t y ) Task Patterns Always Name Tasks 1 2 3 4 5 6 7 8 9 10 # Bad - apt: name: nginx state: present # Good - name: Install nginx apt: name: nginx state: present Use Block for Related Tasks 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 - name: Configure SSL block: - name: Copy SSL certificate copy: src: "{{ ssl_cert }}" dest: /etc/ssl/certs/ mode: '0644' - name: Copy SSL private key copy: src: "{{ ssl_key }}" dest: /etc/ssl/private/ mode: '0600' - name: Enable SSL site file: src: /etc/nginx/sites-available/ssl.conf dest: /etc/nginx/sites-enabled/ssl.conf state: link notify: Reload nginx when: ssl_enabled | bool Error Handling 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 - name: Deploy application block: - name: Pull latest code git: repo: "{{ app_repo }}" dest: "{{ app_path }}" version: "{{ app_version }}" register: git_result - name: Run migrations command: ./manage.py migrate args: chdir: "{{ app_path }}" when: git_result.changed rescue: - name: Rollback to previous version git: repo: "{{ app_repo }}" dest: "{{ app_path }}" version: "{{ previous_version }}" - name: Notify failure slack: token: "{{ slack_token }}" msg: "Deploy failed on {{ inventory_hostname }}" always: - name: Clean up temp files file: path: /tmp/deploy state: absent Variables Variable Precedence (use intentionally) 1 2 3 4 5 6 7 8 9 10 11 12 13 # defaults/main.yml - Easily overridden defaults nginx_worker_processes: auto nginx_worker_connections: 1024 # vars/main.yml - Role-specific constants nginx_user: www-data nginx_conf_path: /etc/nginx/nginx.conf # group_vars/webservers.yml - Group-specific nginx_worker_connections: 4096 # host_vars/web1.yml - Host-specific nginx_worker_processes: 4 Variable Validation 1 2 3 4 5 6 7 8 - name: Validate required variables assert: that: - app_version is defined - app_version | length > 0 - db_password is defined fail_msg: "Required variables are not set" success_msg: "All required variables present" Default Values 1 2 3 4 5 6 7 8 - name: Set configuration template: src: config.j2 dest: /etc/app/config.yml vars: max_connections: "{{ app_max_connections | default(100) }}" timeout: "{{ app_timeout | default(30) }}" debug: "{{ app_debug | default(false) | bool }}" Handlers 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # handlers/main.yml - name: Reload nginx service: name: nginx state: reloaded listen: "reload web server" - name: Restart nginx service: name: nginx state: restarted listen: "restart web server" # In tasks - name: Update nginx config template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: "reload web server" Flush Handlers When Needed 1 2 3 4 5 6 7 8 9 10 11 12 13 14 - name: Install nginx apt: name: nginx state: present notify: Start nginx - name: Flush handlers meta: flush_handlers - name: Configure nginx template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf # nginx is now guaranteed to be running Conditionals Clean Conditionals 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # Use bool filter for string booleans - name: Enable debug mode template: src: debug.conf.j2 dest: /etc/app/debug.conf when: debug_mode | bool # Check if variable is defined and not empty - name: Set custom config copy: content: "{{ custom_config }}" dest: /etc/app/custom.conf when: - custom_config is defined - custom_config | length > 0 # Multiple conditions - name: Deploy to production include_tasks: deploy.yml when: - env == 'production' - deploy_enabled | bool - inventory_hostname in groups['webservers'] Loops Modern Loop Syntax 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 # Simple loop - name: Install packages apt: name: "{{ item }}" state: present loop: - nginx - python3 - htop # Better: Install all at once - name: Install packages apt: name: - nginx - python3 - htop state: present # Loop with index - name: Create users user: name: "{{ item.name }}" uid: "{{ 1000 + index }}" groups: "{{ item.groups }}" loop: "{{ users }}" loop_control: index_var: index label: "{{ item.name }}" # Cleaner output # Dict loop - name: Configure services template: src: "{{ item.key }}.conf.j2" dest: "/etc/{{ item.key }}/config.conf" loop: "{{ services | dict2items }}" when: item.value.enabled | bool Templates Jinja2 Best Practices 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 {# templates/nginx.conf.j2 #} # Managed by Ansible - DO NOT EDIT # Last updated: {{ ansible_date_time.iso8601 }} # Host: {{ inventory_hostname }} worker_processes {{ nginx_worker_processes }}; events { worker_connections {{ nginx_worker_connections }}; } http { {% for server in nginx_servers %} server { listen {{ server.port | default(80) }}; server_name {{ server.name }}; {% if server.ssl | default(false) %} ssl_certificate {{ server.ssl_cert }}; ssl_certificate_key {{ server.ssl_key }}; {% endif %} {% for location in server.locations | default([]) %} location {{ location.path }} { {{ location.directive }}; } {% endfor %} } {% endfor %} } Template Validation 1 2 3 4 5 6 - name: Generate nginx config template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf validate: nginx -t -c %s notify: Reload nginx Idempotency Patterns Check Mode Support 1 2 3 4 5 6 7 8 9 10 11 - name: Get current version command: cat /opt/app/VERSION register: current_version changed_when: false check_mode: false # Always run, even in check mode - name: Deploy new version unarchive: src: "app-{{ target_version }}.tar.gz" dest: /opt/app/ when: current_version.stdout != target_version Custom Changed Conditions 1 2 3 4 5 - name: Run database migration command: ./manage.py migrate --check register: migration_check changed_when: "'No migrations to apply' not in migration_check.stdout" failed_when: migration_check.rc not in [0, 1] Avoid Command When Possible 1 2 3 4 5 6 7 8 9 10 # Bad - not idempotent - name: Create directory command: mkdir -p /opt/app # Good - idempotent - name: Create directory file: path: /opt/app state: directory mode: '0755' Secrets Management Ansible Vault 1 2 3 4 5 6 7 8 9 10 11 # Create encrypted file ansible-vault create secrets.yml # Edit encrypted file ansible-vault edit secrets.yml # Use in playbook ansible-playbook site.yml --ask-vault-pass # Or with password file ansible-playbook site.yml --vault-password-file ~/.vault_pass 1 2 3 4 5 6 7 8 # Encrypted variables file # group_vars/all/vault.yml vault_db_password: !vault | $ANSIBLE_VAULT;1.1;AES256 ... # Reference in playbook db_password: "{{ vault_db_password }}" No Secrets in Logs 1 2 3 4 5 - name: Set database password mysql_user: name: app password: "{{ db_password }}" no_log: true Performance Gather Facts Selectively 1 2 3 4 5 6 7 8 9 10 11 - hosts: webservers gather_facts: false tasks: - name: Quick task without facts ping: # Or gather specific facts - hosts: webservers gather_subset: - network - hardware Async for Long Tasks 1 2 3 4 5 6 7 8 9 10 11 12 13 - name: Run long backup command: /opt/scripts/backup.sh async: 3600 # 1 hour timeout poll: 0 # Don't wait register: backup_job - name: Check backup status async_status: jid: "{{ backup_job.ansible_job_id }}" register: job_result until: job_result.finished retries: 60 delay: 60 Limit Concurrent Execution 1 2 3 4 - hosts: webservers serial: 2 # Two hosts at a time # Or percentage: serial: "25%" # Or batches: serial: [1, 5, 10] Testing with Molecule 1 2 3 4 5 6 7 8 9 10 11 12 13 # molecule/default/molecule.yml dependency: name: galaxy driver: name: docker platforms: - name: instance image: ubuntu:22.04 pre_build_image: true provisioner: name: ansible verifier: name: ansible 1 2 3 4 5 6 7 8 9 10 11 # molecule/default/verify.yml - name: Verify hosts: all tasks: - name: Check nginx is running service: name: nginx state: started check_mode: true register: result failed_when: result.changed 1 2 # Run tests molecule test Good Ansible is boring Ansible. No surprises, no side effects, same result every time. When your playbooks are truly idempotent, running them becomes a confidence-builder rather than a risk. ...

February 25, 2026 Β· 10 min Β· 2060 words Β· Rob Washington

Bash Scripting Patterns: Write Scripts That Don't Embarrass You

Bash scripts have a reputation for being fragile, unreadable hacks. They don’t have to be. These patterns will help you write scripts that are maintainable, debuggable, and safe to run in production. The Essentials: Start Every Script Right 1 2 3 4 #!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' What these do: set -e β€” Exit on any error set -u β€” Error on undefined variables set -o pipefail β€” Catch errors in pipelines IFS=$'\n\t' β€” Safer word splitting (no spaces) Script Template 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 #!/usr/bin/env bash set -euo pipefail # Script metadata readonly SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" readonly SCRIPT_VERSION="1.0.0" # Default values VERBOSE=false DRY_RUN=false CONFIG_FILE="" # Colors (if terminal supports them) if [[ -t 1 ]]; then readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[0;33m' readonly NC='\033[0m' # No Color else readonly RED='' GREEN='' YELLOW='' NC='' fi # Logging functions log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } log_debug() { [[ "$VERBOSE" == true ]] && echo -e "[DEBUG] $*" || true; } # Cleanup function cleanup() { local exit_code=$? # Add cleanup tasks here log_debug "Cleaning up..." exit $exit_code } trap cleanup EXIT # Usage usage() { cat <<EOF Usage: $SCRIPT_NAME [OPTIONS] <argument> Description of what this script does. Options: -h, --help Show this help message -v, --verbose Enable verbose output -n, --dry-run Show what would be done -c, --config FILE Path to config file --version Show version Examples: $SCRIPT_NAME -v input.txt $SCRIPT_NAME --config /etc/myapp.conf data/ EOF } # Parse arguments parse_args() { while [[ $# -gt 0 ]]; do case $1 in -h|--help) usage exit 0 ;; -v|--verbose) VERBOSE=true shift ;; -n|--dry-run) DRY_RUN=true shift ;; -c|--config) CONFIG_FILE="$2" shift 2 ;; --version) echo "$SCRIPT_NAME version $SCRIPT_VERSION" exit 0 ;; -*) log_error "Unknown option: $1" usage exit 1 ;; *) ARGS+=("$1") shift ;; esac done } # Validation validate() { if [[ ${#ARGS[@]} -eq 0 ]]; then log_error "Missing required argument" usage exit 1 fi if [[ -n "$CONFIG_FILE" && ! -f "$CONFIG_FILE" ]]; then log_error "Config file not found: $CONFIG_FILE" exit 1 fi } # Main logic main() { log_info "Starting $SCRIPT_NAME" log_debug "Arguments: ${ARGS[*]}" for arg in "${ARGS[@]}"; do if [[ "$DRY_RUN" == true ]]; then log_info "[DRY RUN] Would process: $arg" else log_info "Processing: $arg" # Actual work here fi done log_info "Done" } # Entry point ARGS=() parse_args "$@" validate main Error Handling Check Command Existence 1 2 3 4 5 6 7 8 9 10 require_command() { if ! command -v "$1" &> /dev/null; then log_error "Required command not found: $1" exit 1 fi } require_command curl require_command jq require_command docker Retry Pattern 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 retry() { local max_attempts=$1 local delay=$2 shift 2 local cmd=("$@") local attempt=1 while (( attempt <= max_attempts )); do if "${cmd[@]}"; then return 0 fi log_warn "Attempt $attempt/$max_attempts failed, retrying in ${delay}s..." sleep "$delay" ((attempt++)) done log_error "Command failed after $max_attempts attempts: ${cmd[*]}" return 1 } # Usage retry 3 5 curl -sf https://api.example.com/health Timeout Pattern 1 2 3 4 5 6 7 8 9 10 11 12 13 14 with_timeout() { local timeout=$1 shift timeout "$timeout" "$@" || { local exit_code=$? if [[ $exit_code -eq 124 ]]; then log_error "Command timed out after ${timeout}s" fi return $exit_code } } # Usage with_timeout 30 curl -s https://api.example.com Safe File Operations Temporary Files 1 2 3 4 5 6 7 # Create temp file that's automatically cleaned up TEMP_FILE=$(mktemp) trap 'rm -f "$TEMP_FILE"' EXIT # Or temp directory TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT Safe Write (Atomic) 1 2 3 4 5 6 7 8 9 10 11 12 safe_write() { local target=$1 local content=$2 local temp_file temp_file=$(mktemp) echo "$content" > "$temp_file" mv "$temp_file" "$target" } # Usage safe_write /etc/myapp/config.json '{"key": "value"}' Backup Before Modify 1 2 3 4 5 6 7 8 9 backup_file() { local file=$1 local backup="${file}.bak.$(date +%Y%m%d_%H%M%S)" if [[ -f "$file" ]]; then cp "$file" "$backup" log_info "Backup created: $backup" fi } Input Validation File Checks 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 validate_file() { local file=$1 [[ -e "$file" ]] || { log_error "File not found: $file"; return 1; } [[ -f "$file" ]] || { log_error "Not a regular file: $file"; return 1; } [[ -r "$file" ]] || { log_error "File not readable: $file"; return 1; } } validate_directory() { local dir=$1 [[ -e "$dir" ]] || { log_error "Directory not found: $dir"; return 1; } [[ -d "$dir" ]] || { log_error "Not a directory: $dir"; return 1; } [[ -w "$dir" ]] || { log_error "Directory not writable: $dir"; return 1; } } Input Sanitization 1 2 3 4 5 6 7 8 sanitize_filename() { local input=$1 # Remove path components and dangerous characters echo "${input##*/}" | tr -cd '[:alnum:]._-' } # Usage safe_name=$(sanitize_filename "$user_input") Configuration Config File Loading 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 load_config() { local config_file=$1 if [[ -f "$config_file" ]]; then log_debug "Loading config: $config_file" # shellcheck source=/dev/null source "$config_file" else log_warn "Config file not found, using defaults: $config_file" fi } # Config file format (config.sh): # DB_HOST="localhost" # DB_PORT=5432 # ENABLE_FEATURE=true Environment Variable Defaults 1 2 3 4 5 6 : "${DB_HOST:=localhost}" : "${DB_PORT:=5432}" : "${LOG_LEVEL:=info}" # Or with validation DB_HOST="${DB_HOST:?DB_HOST environment variable required}" Parallel Execution Using xargs 1 2 3 4 process_files() { find . -name "*.txt" -print0 | \ xargs -0 -P 4 -I {} bash -c 'process_single_file "$1"' _ {} } Using Background Jobs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 parallel_tasks() { local pids=() for item in "${items[@]}"; do process_item "$item" & pids+=($!) done # Wait for all and check results local failed=0 for pid in "${pids[@]}"; do if ! wait "$pid"; then ((failed++)) fi done [[ $failed -eq 0 ]] || log_error "$failed tasks failed" return $failed } Locking Prevent Concurrent Runs 1 2 3 4 5 6 7 8 9 10 11 LOCK_FILE="/var/run/${SCRIPT_NAME}.lock" acquire_lock() { if ! mkdir "$LOCK_FILE" 2>/dev/null; then log_error "Another instance is running (lock: $LOCK_FILE)" exit 1 fi trap 'rm -rf "$LOCK_FILE"' EXIT } acquire_lock With flock 1 2 3 4 5 6 7 8 9 10 11 12 LOCK_FD=200 LOCK_FILE="/var/run/${SCRIPT_NAME}.lock" acquire_lock() { eval "exec $LOCK_FD>$LOCK_FILE" if ! flock -n $LOCK_FD; then log_error "Another instance is running" exit 1 fi } acquire_lock Testing Assertions 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 assert_equals() { local expected=$1 actual=$2 message=${3:-"Assertion failed"} if [[ "$expected" != "$actual" ]]; then log_error "$message: expected '$expected', got '$actual'" return 1 fi } assert_file_exists() { local file=$1 if [[ ! -f "$file" ]]; then log_error "File should exist: $file" return 1 fi } # Usage assert_equals "200" "$status_code" "HTTP status check" assert_file_exists "/tmp/output.txt" Test Mode 1 2 3 4 5 6 7 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then # Script is being run directly main "$@" else # Script is being sourced (for testing) log_debug "Script sourced, not executing main" fi Debugging Debug Mode 1 [[ "${DEBUG:-}" == true ]] && set -x Trace Function Calls 1 2 3 4 5 6 7 8 trace() { log_debug "TRACE: ${FUNCNAME[1]}(${*})" } my_function() { trace "$@" # function body } Common Pitfalls Quote Everything 1 2 3 4 5 # Bad if [ $var == "value" ]; then # Good if [[ "$var" == "value" ]]; then Use Arrays for Lists 1 2 3 4 5 6 7 # Bad files="file1.txt file2.txt file with spaces.txt" for f in $files; do echo "$f"; done # Good files=("file1.txt" "file2.txt" "file with spaces.txt") for f in "${files[@]}"; do echo "$f"; done Check Command Success 1 2 3 4 5 6 7 8 9 10 11 # Bad output=$(some_command) echo "$output" # Good if output=$(some_command 2>&1); then echo "$output" else log_error "Command failed: $output" exit 1 fi Good bash scripts are defensive, verbose when needed, and fail fast. Start with the template, add what you need, and resist the urge to be clever. ...

February 25, 2026 Β· 8 min Β· 1525 words Β· Rob Washington

jq Mastery: JSON Processing on the Command Line

Every API returns JSON. Every config file is JSON. If you’re not fluent in jq, you’re copying data by hand like it’s 1995. The Basics 1 2 3 4 5 6 7 8 9 10 # Pretty print echo '{"name":"test","value":42}' | jq '.' # Extract a field echo '{"name":"test","value":42}' | jq '.name' # "test" # Raw output (no quotes) echo '{"name":"test","value":42}' | jq -r '.name' # test Working with APIs 1 2 3 4 5 6 7 8 # GitHub API curl -s https://api.github.com/users/torvalds | jq '.login, .public_repos' # Extract specific fields curl -s https://api.github.com/repos/stedolan/jq | jq '{name, stars: .stargazers_count, language}' # AWS CLI (already outputs JSON) aws ec2 describe-instances | jq '.Reservations[].Instances[] | {id: .InstanceId, state: .State.Name}' Array Operations 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # Sample data DATA='[{"name":"alice","age":30},{"name":"bob","age":25},{"name":"carol","age":35}]' # First element echo $DATA | jq '.[0]' # Last element echo $DATA | jq '.[-1]' # Slice echo $DATA | jq '.[0:2]' # All names echo $DATA | jq '.[].name' # Array of names echo $DATA | jq '[.[].name]' # Length echo $DATA | jq 'length' Filtering 1 2 3 4 5 6 7 8 9 10 11 # Select by condition echo $DATA | jq '.[] | select(.age > 28)' # Multiple conditions echo $DATA | jq '.[] | select(.age > 25 and .name != "carol")' # Contains echo '[{"tags":["web","api"]},{"tags":["cli"]}]' | jq '.[] | select(.tags | contains(["api"]))' # Has key echo '{"a":1,"b":null}' | jq 'has("a"), has("c")' Transformation 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Add/modify fields echo '{"name":"test"}' | jq '. + {status: "active", count: 0}' # Update existing field echo '{"count":5}' | jq '.count += 1' # Delete field echo '{"a":1,"b":2,"c":3}' | jq 'del(.b)' # Rename key echo '{"old_name":"value"}' | jq '{new_name: .old_name}' # Map over array echo '[1,2,3,4,5]' | jq 'map(. * 2)' # Map with objects echo $DATA | jq 'map({username: .name, birth_year: (2026 - .age)})' String Operations 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 # Concatenation echo '{"first":"John","last":"Doe"}' | jq '.first + " " + .last' # String interpolation echo '{"name":"test","ver":"1.0"}' | jq '"\(.name)-\(.ver).tar.gz"' # Split echo '{"path":"/usr/local/bin"}' | jq '.path | split("/")' # Join echo '["a","b","c"]' | jq 'join(",")' # Upper/lower echo '"Hello World"' | jq 'ascii_downcase' echo '"Hello World"' | jq 'ascii_upcase' # Test regex echo '{"email":"test@example.com"}' | jq '.email | test("@")' # Replace echo '"hello world"' | jq 'gsub("world"; "jq")' Conditionals 1 2 3 4 5 6 7 8 9 10 11 # If-then-else echo '{"status":200}' | jq 'if .status == 200 then "ok" else "error" end' # Alternative operator (default value) echo '{"a":1}' | jq '.b // "default"' # Null handling echo '{"a":null}' | jq '.a // "was null"' # Error handling echo '{}' | jq '.missing.nested // "not found"' Grouping and Aggregation 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 LOGS='[ {"level":"error","msg":"failed"}, {"level":"info","msg":"started"}, {"level":"error","msg":"timeout"}, {"level":"info","msg":"completed"} ]' # Group by field echo $LOGS | jq 'group_by(.level)' # Count per group echo $LOGS | jq 'group_by(.level) | map({level: .[0].level, count: length})' # Unique values echo $LOGS | jq '[.[].level] | unique' # Sort echo $DATA | jq 'sort_by(.age)' # Reverse sort echo $DATA | jq 'sort_by(.age) | reverse' # Min/max echo '[5,2,8,1,9]' | jq 'min, max' # Sum echo '[1,2,3,4,5]' | jq 'add' # Average echo '[1,2,3,4,5]' | jq 'add / length' Constructing Output 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 # Build new object curl -s https://api.github.com/users/torvalds | jq '{ username: .login, repos: .public_repos, profile: .html_url }' # Build array echo '{"users":[{"name":"a"},{"name":"b"}]}' | jq '[.users[].name]' # Multiple outputs to array echo '{"a":1,"b":2}' | jq '[.a, .b, .a + .b]' # Key-value pairs echo '{"a":1,"b":2}' | jq 'to_entries' # [{"key":"a","value":1},{"key":"b","value":2}] # Back to object echo '[{"key":"a","value":1}]' | jq 'from_entries' # Transform keys echo '{"old_a":1,"old_b":2}' | jq 'with_entries(.key |= ltrimstr("old_"))' Real-World Examples Parse AWS Instance List 1 2 3 4 5 aws ec2 describe-instances | jq -r ' .Reservations[].Instances[] | [.InstanceId, .State.Name, (.Tags[]? | select(.Key=="Name") | .Value) // "unnamed"] | @tsv ' Filter Docker Containers 1 2 3 4 5 6 docker inspect $(docker ps -q) | jq '.[] | { name: .Name, image: .Config.Image, status: .State.Status, ip: .NetworkSettings.IPAddress }' Process Log Files 1 2 3 4 5 6 7 # Count errors by type cat app.log | jq -s 'group_by(.error_type) | map({type: .[0].error_type, count: length}) | sort_by(.count) | reverse' # Extract errors from last hour cat app.log | jq --arg cutoff "$(date -d '1 hour ago' -Iseconds)" ' select(.timestamp > $cutoff and .level == "error") ' Transform Config Files 1 2 3 4 5 6 7 8 # Merge configs jq -s '.[0] * .[1]' base.json override.json # Update nested value jq '.database.host = "newhost.example.com"' config.json # Add to array jq '.allowed_ips += ["10.0.0.5"]' config.json Generate Reports 1 2 3 4 5 6 # Kubernetes pod status kubectl get pods -o json | jq -r ' .items[] | [.metadata.name, .status.phase, (.status.containerStatuses[0].restartCount // 0)] | @tsv ' | column -t Useful Flags 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # Compact output (no pretty print) jq -c '.' # Raw output (no quotes on strings) jq -r '.name' # Raw input (treat input as string, not JSON) jq -R 'split(",")' # Slurp (read all inputs into array) cat *.json | jq -s '.' # Pass variable jq --arg name "test" '.name = $name' # Pass JSON variable jq --argjson count 42 '.count = $count' # Read from file jq --slurpfile users users.json '.users = $users' # Exit with error if output is null/false jq -e '.important_field' && echo "exists" # Sort keys in output jq -S '.' Output Formats 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # Tab-separated echo $DATA | jq -r '.[] | [.name, .age] | @tsv' # CSV echo $DATA | jq -r '.[] | [.name, .age] | @csv' # URI encoding echo '{"q":"hello world"}' | jq -r '.q | @uri' # Base64 echo '{"data":"secret"}' | jq -r '.data | @base64' # Shell-safe echo '{"cmd":"echo hello"}' | jq -r '.cmd | @sh' Debugging 1 2 3 4 5 6 7 8 9 10 11 # Show type echo '{"a":[1,2,3]}' | jq '.a | type' # Show keys echo '{"a":1,"b":2}' | jq 'keys' # Debug output (shows intermediate values) echo '{"x":{"y":{"z":1}}}' | jq '.x | debug | .y | debug | .z' # Path to value echo '{"a":{"b":{"c":1}}}' | jq 'path(.. | select(. == 1))' Quick Reference 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 # Identity . # Field access .field .field.nested # Array access .[0] .[-1] .[2:5] # Iterate array .[] # Pipe .[] | .name # Collect into array [.[] | .name] # Object construction {newkey: .oldkey} # Conditionals if COND then A else B end VALUE // DEFAULT # Comparison ==, !=, <, >, <=, >= and, or, not # Array functions map(f), select(f), sort_by(f), group_by(f), unique, length, first, last, nth(n), flatten, reverse, contains(x), inside(x), add, min, max # String functions split(s), join(s), test(re), match(re), gsub(re;s), ascii_downcase, ascii_upcase, ltrimstr(s), rtrimstr(s), startswith(s), endswith(s) # Object functions keys, values, has(k), in(o), to_entries, from_entries, with_entries(f) # Type functions type, isnumber, isstring, isnull, isboolean, isarray, isobject jq turns JSON from a data format into a query language. Once you internalize the pipe-and-filter model, you’ll wonder how you ever survived without it. ...

February 25, 2026 Β· 7 min Β· 1346 words Β· Rob Washington

Systemd Timers: The Modern Cron Replacement

Cron has run scheduled tasks since 1975. It works, but systemd timers offer significant advantages: integrated logging, dependency management, randomized delays, and calendar-based scheduling that actually makes sense. Why Switch from Cron? Logging: Timer output goes to journald. No more digging through mail or custom log files. Dependencies: Wait for network, mounts, or other services before running. Accuracy: Monotonic timers don’t drift. Calendar timers handle DST correctly. Visibility: systemctl list-timers shows all scheduled jobs and when they’ll run next. ...

February 25, 2026 Β· 6 min Β· 1181 words Β· Rob Washington

Makefiles for Modern Development: Beyond C Compilation

Make was designed for compiling C programs in 1976. Nearly 50 years later, it’s still one of the most practical automation tools availableβ€”not for its original purpose, but as a universal task runner. Why Make in 2026? It’s already installed. Every Unix system has make. No npm install, no pip, no version managers. It’s declarative. Define what you want, not how to get there (with dependencies handled automatically). It’s documented. make help can list all your targets. The Makefile itself is documentation. ...

February 25, 2026 Β· 7 min Β· 1444 words Β· Rob Washington

GitHub Actions Self-Hosted Runners: Complete Setup Guide

When GitHub-hosted runners aren’t enoughβ€”when you need GPU access, specific hardware, private network connectivity, or just want to stop paying per-minuteβ€”self-hosted runners are the answer. Why Self-Hosted? Performance: Your hardware, your speed. No cold starts, local caching, faster artifact access. Cost: After a certain threshold, self-hosted is dramatically cheaper. GitHub-hosted minutes add up fast for active repos. Access: Private networks, internal services, specialized hardware, air-gapped environments. Control: Exact OS versions, pre-installed dependencies, custom security configurations. ...

February 25, 2026 Β· 5 min Β· 1008 words Β· Rob Washington