Home / Courses / CI/CD for Beginners / II — Hands-On Practice
II — Hands-On Practice
Article 4 of 6

Build, Secrets & Environment Variables

Upgrading a basic pipeline with three things: a matrix build to test against multiple Node versions at once, GitHub Secrets for storing credentials safely, and a separate build job that produces a deploy-ready artifact.

July 10, 2026

Our workflow from the previous article already runs, but it's still pretty basic: one job, one Node version, and no concept yet of storing credentials safely. In this article we upgrade three things: matrix builds, GitHub Secrets, and a separate build job that produces an artifact.

Matrix Build — Testing Against Multiple Node Versions at Once

Sometimes we need to make sure our code works across several Node versions — for example, because part of the team is still on Node 18 while others have moved to Node 20. Instead of manually creating separate jobs, GitHub Actions has a feature called a matrix.

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

With strategy.matrix, this single job definition automatically gets "cloned" into two jobs that run in parallel: one on Node 18, one on Node 20. If either one fails, we immediately know which Node version has the problem.

One important note: since the test job is now split into two separate checks, if you already set up branch protection in the previous article, go back to Settings → Branches and update it so both status checks (test (18) and test (20)) are required to pass.

GitHub Secrets — Storing Credentials Safely

Now imagine you need an API key to connect to an external service — a payment gateway, an email service, anything. Never hardcode credentials in your code or commit them to the repo, even if the repo is private. The solution: GitHub Secrets.

The steps:

  • Go to Settings → Secrets and variables → Actions → New repository secret.
  • Name: DEMO_API_KEY, value: any value for the demo (e.g. sk_demo_12345).
  • Save.
  • This secret is encrypted, never appears in your code, and can only be accessed from inside a workflow through the secrets context.

    Add a new step to the build job (covered in the next section):

    - name: Demo of using a secret (with automatic masking)
      env:
        DEMO_API_KEY: ${{ secrets.DEMO_API_KEY }}
      run: |
        echo "Trying to print it directly (GitHub will auto-mask this): $DEMO_API_KEY"
        echo "DEMO_API_KEY is set, length: ${#DEMO_API_KEY} characters"
    

    Pay close attention to the resulting log: even though we try to echo the secret's value directly, GitHub automatically replaces its display with *** in the log. This is a built-in security feature so secrets don't leak through logs, even if a developer accidentally prints them.

    But don't rely on this masking as your only protection. Best practice: avoid printing secrets at all. A safer way to just verify a secret is set is to check its character length, like in the example above.

    The build Job — Separating Test from Packaging with needs

    Now let's separate concerns. The test job focuses on making sure the code is correct. A new job, build, focuses on preparing a deploy-ready package — this is what becomes the "artifact" whose concept was already covered in the pipeline anatomy article.

    build:
      needs: test
      runs-on: ubuntu-latest
    
      steps:
        - uses: actions/checkout@v4
        - uses: actions/setup-node@v4
          with:
            node-version: '20'
            cache: 'npm'
    
        - name: Install production dependencies
          run: npm ci --omit=dev
    
        - name: Demo of using a secret (with automatic masking)
          env:
            DEMO_API_KEY: ${{ secrets.DEMO_API_KEY }}
          run: |
            echo "DEMO_API_KEY is set, length: ${#DEMO_API_KEY} characters"
    
        - name: Package the app into an artifact
          run: zip -r release.zip . -x "tests/*" -x ".git/*" -x "*.test.js" -x "coverage/*"
    
        - name: Upload artifact
          uses: actions/upload-artifact@v4
          with:
            name: task-tracker-api-build
            path: release.zip
            retention-days: 7
    

    Key points to understand:

  • needs: test — the build job only starts once ALL jobs in the test matrix succeed. This is what makes jobs run sequentially, unlike the parallel default.
  • npm ci --omit=dev — for packaging, we don't need dev dependencies like Jest, so we only install what's needed at runtime.
  • zip -r release.zip ... — creates a single compressed file containing the deploy-ready code, excluding files we don't need (tests, coverage, .git).
  • actions/upload-artifact@v4 — uploads release.zip so it can be downloaded from the run page, or used by another job later on (we'll reuse this concept when we get to deployment).
  • Once the run finishes, scroll to the bottom of the run page and you'll see the task-tracker-api-build artifact, downloadable directly. Try downloading and extracting it — the contents are exactly our deploy-ready app code, with no test files.

    Why Does This Matter?

    Why bother separating build from test, and using an artifact? Because once code is built once and passes tests, we don't need to rebuild from scratch every time we deploy. The same, already-tested artifact is what we carry all the way to production — the "build once, deploy everywhere" principle. This also prevents the classic "it worked in staging but behaves differently in production" case, because the artifact is identical.

    Summary

  • Matrix build for testing multiple Node versions at once.
  • GitHub Secrets for storing credentials safely.
  • A separate build job that produces an artifact via needs, so the ordering is correct.
  • Topics

    CI/CDGitHub Actions