GitHub Actions CI/CD: The Complete Guide for 2026
GitHub Actions is the CI/CD platform built directly into GitHub. Every push, pull request, or scheduled event can trigger automated workflows that build, test, lint, and deploy your code — without leaving your repository. This guide covers everything from writing your first workflow to advanced patterns like matrix builds, Docker image publishing, reusable workflows, and production deployment strategies.
1. GitHub Actions Fundamentals
Before writing your first workflow, understand the four building blocks:
Repository
└── .github/workflows/
└── ci.yml <-- Workflow file (YAML)
├── on: push <-- Trigger (event)
└── jobs:
└── build: <-- Job
├── runs-on: ubuntu-latest <-- Runner
└── steps: <-- Steps
├── uses: actions/checkout@v4
└── run: npm test
- Workflow — a YAML file in
.github/workflows/that defines an automated process. A repository can have multiple workflows. - Job — a set of steps that execute on the same runner. Jobs run in parallel by default; use
needsfor sequential ordering. - Step — a single task within a job. Either runs a shell command (
run) or uses a reusable action (uses). - Runner — the server that executes your job. GitHub provides hosted runners (Ubuntu, Windows, macOS) or you can use self-hosted runners.
How it works: when an event matches a workflow trigger, GitHub spins up a fresh virtual machine for each job, clones your code, runs the steps in order, and reports the result. Each job gets a clean environment — nothing persists between jobs unless you explicitly use artifacts or caching.
2. Workflow YAML Syntax
Every workflow file lives in .github/workflows/ and follows this structure:
# .github/workflows/ci.yml
name: CI Pipeline # Display name in the Actions tab
on: # When to trigger
push:
branches: [main]
pull_request:
branches: [main]
permissions: # Explicit permissions (security best practice)
contents: read
env: # Workflow-level environment variables
NODE_ENV: production
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest # Runner OS
timeout-minutes: 15 # Kill job if it hangs
env: # Job-level environment variables
CI: true
steps:
- name: Checkout code
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 tests
run: npm test
- name: Build
run: npm run build
3. Workflow Triggers
The on key controls when your workflow runs. Here are the most common triggers:
Push and Pull Request
on:
push:
branches: [main, develop]
paths:
- 'src/**' # Only trigger when source files change
- 'package.json'
paths-ignore:
- '**.md' # Skip if only docs changed
- 'docs/**'
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
Schedule (Cron)
on:
schedule:
- cron: '0 6 * * 1' # Every Monday at 6:00 AM UTC
- cron: '0 0 * * *' # Daily at midnight UTC
Cron syntax: minute hour day-of-month month day-of-week. Scheduled workflows run on the default branch only.
Manual Dispatch
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- staging
- production
dry_run:
description: 'Dry run (skip actual deployment)'
required: false
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to ${{ github.event.inputs.environment }}"
- run: |
if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then
echo "Dry run - skipping deployment"
else
echo "Deploying for real"
fi
Other Useful Triggers
on:
release:
types: [published] # When a GitHub Release is created
workflow_run:
workflows: ["CI"] # Run after another workflow completes
types: [completed]
repository_dispatch: # Triggered by external API call
types: [deploy]
issue_comment: # React to comments on issues/PRs
types: [created]
4. Common CI Workflows
Build, Test, and Lint
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint # Run tests only if lint passes
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
build:
runs-on: ubuntu-latest
needs: test # Build only if tests pass
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
Python CI with pytest
name: Python CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12', cache: pip }
- run: pip install -r requirements.txt
- run: pytest --cov=src --cov-report=xml
- uses: codecov/codecov-action@v4
with: { file: coverage.xml }
Go CI
name: Go CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: '1.22' }
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build -v ./...
5. CD Workflows — Deploy to Cloud
Deploy to Vercel
name: Deploy to Vercel
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci && npm run build
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
Deploy to Netlify
name: Deploy to Netlify
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci && npm run build
- uses: nwtgck/actions-netlify@v3
with:
publish-dir: './dist'
production-deploy: true
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
Deploy to AWS (S3 + CloudFront)
name: Deploy to AWS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions
aws-region: us-east-1
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci && npm run build
- name: Sync to S3
run: aws s3 sync dist/ s3://my-bucket --delete
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/*"
Security note: AWS OIDC authentication (shown above) is strongly preferred over storing long-lived access keys as secrets. OIDC generates short-lived tokens scoped to each workflow run.
6. Docker Image Builds and Registry Push
name: Docker Build and Push
on:
push:
branches: [main]
tags: ['v*']
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
This workflow builds a Docker image on every push to main and on version tags, pushes it to GitHub Container Registry (ghcr.io), and uses GitHub Actions cache for layer caching. The metadata action automatically generates semantic version tags from git tags.
Multi-Platform Builds
- name: Set up QEMU (for cross-platform builds)
uses: docker/setup-qemu-action@v3
- name: Build and push (multi-arch)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
7. Secrets and Environment Variables
Repository Secrets
Store sensitive values in Settings > Secrets and variables > Actions. Reference them with the secrets context:
steps:
- name: Deploy
run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# Secrets are masked in logs - GitHub replaces them with ***
GitHub Environments
Environments add protection rules like required reviewers, wait timers, and deployment branches:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging # Uses staging-specific secrets
steps:
- run: echo "Deploying to ${{ vars.API_URL }}"
# vars.API_URL is set per-environment in repo settings
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production
url: https://myapp.com # Shows in the PR deployment status
steps:
- run: echo "Deploying to production"
env:
API_KEY: ${{ secrets.PROD_API_KEY }}
GITHUB_TOKEN
Every workflow run automatically gets a GITHUB_TOKEN with permissions scoped to the repository. Control its permissions explicitly:
permissions:
contents: read
packages: write
pull-requests: write # Needed to comment on PRs
issues: write # Needed to create/update issues
# Use it in steps:
steps:
- name: Comment on PR
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'All checks passed! Ready for review.'
})
8. Matrix Builds
Matrix strategies run your job across multiple configurations in parallel. Test across Node versions, operating systems, or any combination:
Node.js Matrix
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Don't cancel other jobs if one fails
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
exclude:
- os: macos-latest
node-version: 18 # Skip Node 18 on macOS
include:
- os: ubuntu-latest
node-version: 22
experimental: true # Flag for custom logic
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
Python Matrix
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest
Key options:
fail-fast: false— continue all matrix jobs even if one fails (default istrue)max-parallel: 3— limit concurrent jobs to avoid rate limits or resource constraintsexclude— remove specific combinations from the matrixinclude— add extra combinations or properties to existing ones
9. Caching Dependencies
Caching avoids downloading dependencies on every run, cutting minutes off your workflow. GitHub provides 10 GB of cache per repository.
npm (Built-in Cache)
# The setup-node action has built-in caching:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # Caches ~/.npm based on package-lock.json
- run: npm ci # Installs from cache when available
pip (Built-in Cache)
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip # Caches pip packages based on requirements.txt
- run: pip install -r requirements.txt
Gradle (Manual Cache)
- name: Cache Gradle packages
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Build with Gradle
run: ./gradlew build
Generic Cache Action
# Cache any directory with actions/cache
- uses: actions/cache@v4
with:
path: ~/.cache/my-tool
key: my-tool-${{ runner.os }}-${{ hashFiles('config.lock') }}
restore-keys: |
my-tool-${{ runner.os }}-
Cache tips:
- Use
hashFiles()on lock files to invalidate the cache when dependencies change - Provide
restore-keysas fallback patterns for partial cache hits - Caches are scoped to the branch; PRs can also restore caches from the base branch
- Caches expire after 7 days of not being accessed
10. Reusable Workflows and Composite Actions
Reusable Workflows
Extract common workflow patterns into reusable templates that other workflows can call:
# .github/workflows/reusable-deploy.yml
name: Reusable Deploy
on:
workflow_call: # This makes it callable
inputs:
environment:
required: true
type: string
app_version:
required: true
type: string
secrets:
deploy_key:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- run: |
echo "Deploying version ${{ inputs.app_version }}"
echo "to ${{ inputs.environment }}"
env:
DEPLOY_KEY: ${{ secrets.deploy_key }}
# .github/workflows/release.yml — caller workflow
name: Release
on:
push:
tags: ['v*']
jobs:
deploy-staging:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
app_version: ${{ github.ref_name }}
secrets:
deploy_key: ${{ secrets.STAGING_DEPLOY_KEY }}
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
app_version: ${{ github.ref_name }}
secrets:
deploy_key: ${{ secrets.PROD_DEPLOY_KEY }}
Composite Actions
Bundle multiple steps into a single reusable action that you call with uses:
# .github/actions/setup-and-build/action.yml
name: 'Setup and Build'
description: 'Install deps and build the project'
inputs:
node-version:
description: 'Node.js version'
required: false
default: '20'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
shell: bash
- run: npm run build
shell: bash
# Use it in a workflow:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-and-build
with:
node-version: '20'
- run: npm test
When to use which:
- Reusable workflows — share entire jobs (including runner selection, environment, and permissions) across workflows
- Composite actions — share a set of steps within a job, ideal for setup/build patterns used in multiple jobs
11. Self-Hosted Runners
Self-hosted runners give you full control over hardware, OS, and software. Use them for GPU access, special hardware, persistent caches, or to avoid per-minute billing.
# Use self-hosted runner in a workflow:
jobs:
build:
runs-on: self-hosted # Matches any self-hosted runner
# Or be specific with labels:
# runs-on: [self-hosted, linux, gpu]
steps:
- uses: actions/checkout@v4
- run: nvidia-smi # Access GPU on your machine
- run: python train.py
Install a runner by downloading the binary from your repo's Settings > Actions > Runners, running ./config.sh with your repo URL and token, then sudo ./svc.sh install to run as a service.
Best practices: run in Docker or ephemeral VMs for isolation, never use self-hosted runners with public repos (anyone can fork and run code on your machine), assign custom labels for job routing, and use runner groups in organizations to control access.
12. Common Patterns
Concurrency — Cancel Redundant Runs
# Cancel previous runs of the same workflow on the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# For production deploys, wait instead of canceling:
concurrency:
group: deploy-production
cancel-in-progress: false
Monorepo — Run Only Affected Packages
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
frontend:
- 'packages/frontend/**'
backend:
- 'packages/backend/**'
shared:
- 'packages/shared/**'
test-frontend:
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/frontend && npm ci && npm test
test-backend:
needs: detect-changes
if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/backend && npm ci && npm test
Conditional Deployment
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy:
needs: test
runs-on: ubuntu-latest
# Only deploy on main branch pushes, not on PRs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: ./deploy.sh
Automated Releases with Changelog
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for changelog
- name: Generate changelog
id: changelog
run: |
PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
[ -n "$PREV" ] && RANGE="${PREV}..HEAD" || RANGE="HEAD~20..HEAD"
CHANGES=$(git log $RANGE --pretty=format:"- %s (%h)" --no-merges)
echo "changes<> $GITHUB_OUTPUT
echo "$CHANGES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v2
with:
body: |
## What's Changed
${{ steps.changelog.outputs.changes }}
generate_release_notes: true
Service Containers for Integration Tests
jobs:
integration-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
13. Security Best Practices
- Pin action versions to commit SHAs — use
actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29instead of@v4to prevent supply-chain attacks - Set minimal permissions — always declare
permissionsat the workflow or job level - Use OIDC for cloud providers — avoid storing long-lived cloud credentials as secrets
- Never echo secrets — GitHub masks known secrets, but be careful with derived values
- Review third-party actions — check the source code before using actions from unknown publishers
- Use environments with protection rules — require approvals for production deployments
- Limit GITHUB_TOKEN permissions — use
permissions: read-allor specific scopes, never leave it at default write-all
# Security-hardened workflow example:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# Pin to SHA instead of tag
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4
with:
node-version: 20
14. Debugging Failed Workflows
# Enable debug logging via repository secrets:
# ACTIONS_RUNNER_DEBUG = true (runner diagnostic logs)
# ACTIONS_STEP_DEBUG = true (step debug output)
# Debug context in a step:
- run: |
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }} | SHA: ${{ github.sha }}"
echo "Actor: ${{ github.actor }} | Runner: ${{ runner.os }}"
# Interactive SSH debugging on failure:
- uses: mxschmitt/action-tmate@v3
if: failure()
timeout-minutes: 15
Common failure causes: permission denied (check permissions block), secret not found (secrets are unavailable from fork PRs), cache miss (verify hashFiles() glob patterns), timeouts (increase timeout-minutes), and rate limits (reduce parallelism with max-parallel).
Frequently Asked Questions
How much does GitHub Actions cost?
GitHub Actions is free for public repositories with unlimited minutes. For private repositories, free plans include 2,000 minutes per month, Pro plans include 3,000 minutes, and Team plans include 3,000 minutes. Additional minutes are billed per minute with different rates for Linux ($0.008/min), Windows ($0.016/min), and macOS ($0.08/min) runners. Self-hosted runners are always free regardless of plan. Storage for artifacts and caches is shared with GitHub Packages and has a 500 MB free tier on the free plan.
What is the difference between a job and a step in GitHub Actions?
A job is a set of steps that execute on the same runner (virtual machine). Jobs run in parallel by default unless you define dependencies with the needs keyword. Each job gets a fresh runner environment. A step is a single task within a job — either a shell command with run or a reusable action with uses. Steps within a job always run sequentially and share the same filesystem, environment variables, and working directory.
How do I pass secrets to GitHub Actions workflows?
Store secrets in your repository settings under Settings > Secrets and variables > Actions. Reference them in workflows using the secrets context: ${{ secrets.MY_SECRET }}. Secrets are encrypted at rest, masked in log output, and not passed to workflows triggered by pull requests from forks. For environment-specific secrets (staging vs production), create GitHub Environments and assign secrets to each one. Organization-level secrets can be shared across multiple repositories.
How can I speed up GitHub Actions workflows?
Use dependency caching with actions/cache or built-in cache options in setup actions (actions/setup-node has a cache parameter). Run independent jobs in parallel instead of sequentially. Use path filters to skip workflows when irrelevant files change. Use matrix strategies to parallelize testing across versions. Use concurrency groups with cancel-in-progress: true to cancel redundant runs when new commits are pushed. Consider larger runners for compute-heavy tasks.
Can I run GitHub Actions locally for testing?
Yes, use the open-source tool act (github.com/nektos/act) to run GitHub Actions workflows locally. Install it with brew install act or by downloading the binary, then run act in your repository root. It uses Docker to simulate the GitHub runner environment. Note that some features like secrets, GITHUB_TOKEN, and certain runner features may behave differently locally. You can pass secrets with act -s MY_SECRET=value or use a .secrets file.
Conclusion
GitHub Actions turns your repository into a complete CI/CD platform. Start with a simple workflow that runs your tests on every push, then layer on caching, matrix builds, and deployment steps as your project grows. The key principles that make workflows reliable are: keep jobs focused and fast, cache aggressively, use environments with protection rules for production, pin action versions for security, and set explicit permissions on every workflow.
For large teams, invest in reusable workflows and composite actions to standardize your CI/CD patterns across repositories. Use concurrency groups to avoid wasting runner minutes on outdated commits. And remember that the best workflow is one that runs fast enough that developers never think about skipping it.
Related Resources
- Docker Containers Beginner's Guide — learn containerization fundamentals
- Git Branching Strategies — workflows that pair well with CI/CD pipelines
- YAML Complete Guide — master the syntax used in every workflow file
- Terraform Complete Guide — infrastructure as code for cloud deployments
- YAML Validator — validate workflow files before pushing
- Docker Commands Cheat Sheet — quick reference for Docker CLI