← Back
DevOps 7 min read

GitHub Actions: from commit to deploy with no manual step

GitHub Actions looks intimidating because of the YAML and the context variables, but 90% of the errors come down to syntax or a misunderstood context variable. This guide builds a real, production-grade Docker pipeline from scratch.

GitHub Actions has a reputation for being hard. It isn't — but the entry curve is intimidating: YAML with strict indentation, context variables with their own syntax, and documentation that assumes you already know what you're doing.

Two mistakes account for 90% of what goes wrong in GitHub Actions: wrong indentation, and misunderstood context variables. This guide exists so you don't lose hours to either one.

The right mental model

Before you write a single line of YAML, you need the vocabulary straight. GitHub Actions has five core concepts:

Workflow — The YAML file that defines the whole automation. It lives in .github/workflows/ and you can have several per repository.

Trigger — The event that fires the workflow. It can be a push to main, a pull request, a schedule, or even a manual event.

Job — A group of steps that runs on the same virtual machine. Jobs run in parallel by default; you can make them dependent with needs:.

Runner — The virtual machine that executes the job. ubuntu-latest is the most common one. GitHub provides them free up to a monthly limit.

Step — The smallest unit of work. Every step is either a shell command (run:) or a reusable third-party action (uses:).

Confusing run: with uses: is one of the most frequent errors. run: executes commands directly on the runner. uses: delegates to an Action — a package of logic somebody already wrote and published.

The mistakes you're going to make (and how to avoid them)

Wrong indentation. YAML is sensitive to whitespace. A step indented wrong inside a job produces a cryptic error. Use a YAML linter in your editor — VSCode has one built in.

Hardcoding secrets in files. Never put credentials in the YAML. If you do it and commit, it's already too late — the secret is in the git history even if you delete it afterwards. Secrets go in the repository configuration on GitHub, not in the code.

Mixing up run: and uses:. They're mutually exclusive within a step. A step that starts with uses: can't also have run:.

Forgetting the permissions: block. Plenty of Actions need explicit permissions to read the repo, write packages, or comment on PRs. Without the block, they fail with an authorization error that tells you almost nothing.

Ignoring fetch-depth. By default, actions/checkout@v4 does a shallow clone (the last commit only). If your pipeline needs the full history — to generate changelogs, or for tools that analyze diffs — you have to add fetch-depth: 0.

Burning free minutes. GitHub's runners come with a monthly limit. Long jobs that could be parallelized or could cache their dependencies eat into that limit for nothing.

Secrets and environment variables

Secrets are encrypted values GitHub stores and never shows in the logs. You configure them under Settings → Secrets and variables → Actions and reference them with the ${{ secrets.NOMBRE }} syntax.

Environment variables are for configuration that isn't sensitive — staging URLs, environment names, feature flags.

GITHUB_TOKEN is a special case: GitHub injects it into every workflow automatically, with nothing for you to configure. It carries permissions over the current repository, and it's what you'll use to authenticate against the GitHub Container Registry.

env:
  NODE_ENV: production
  API_URL: https://api.staging.example.com

steps:
  - name: Deploy
    run: ./deploy.sh
    env:
      API_KEY: ${{ secrets.API_KEY }}

The real pipeline: build and push a Docker image to GHCR

Enough theory. Here's a complete, production-grade workflow that builds a Docker image from the repo's Dockerfile and pushes it to the GitHub Container Registry (GHCR).

name: Build & Push Docker Image

on:
  push:
    branches: [ "main" ]

jobs:
  build-and-push:
    name: Build & Push to GHCR
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build & Push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest

A few things worth pointing out about this workflow:

permissions: is explicit. contents: read to clone the repo, packages: write to push to GHCR. Without this block, the push step fails with a 403.

${{ github.sha }} tags the image with the exact commit hash. That makes every image traceable — if a deploy breaks production, you know exactly which commit you're running.

No manual secrets. GITHUB_TOKEN is auto-injected. The pipeline works without anyone on the team configuring anything in Settings.

docker/build-push-action@v6 abstracts buildkit and the push into a single step. Without this Action, it would be five manual commands plus handling credentials by hand.

Debugging when something fails

GitHub Actions logs are detailed, but not always obvious. Four strategies that work:

Turn on debug logging. Add a secret named ACTIONS_STEP_DEBUG with the value true. The workflow then logs every internal command the Actions execute. Noisy, but useful when you can't tell why a step is failing.

Inspect the context with echo. Context variables (github, env, secrets) sometimes hold different values from the ones you expect. A debug step that prints them saves you half an hour of confusion:

- name: Debug context
  run: |
    echo "Actor: ${{ github.actor }}"
    echo "Repo: ${{ github.repository }}"
    echo "SHA: ${{ github.sha }}"
    echo "Ref: ${{ github.ref }}"

Use if: always() on diagnostic steps so they run even when earlier steps fail:

- name: Dump logs
  if: always()
  run: cat /tmp/build.log

Try it locally with act. The nektos/act tool runs GitHub Actions workflows on your machine using Docker. It isn't perfect — it doesn't reproduce every runner exactly — but it's useful for iterating without burning minutes or waiting for GitHub to queue the job.

How to keep learning

Tutorials show you clean examples. Real repositories show you how complexity actually gets handled.

The biggest jump in understanding comes from reading the .github/workflows/ directories of well-known open source projects. Projects like Next.js, Astro, or any popular CLI have pipelines that handle cases no tutorial covers: version matrices, conditional deployments, automated releases, dependency caching.

Find a project you use in production, open its workflows folder, and read it like application code — because that's what it is.

Next · Applied AI · 2 min When writing code gets cheap, the work shifts toward judgment Read next →