Stop Using Netlify’s Git Integration — Here’s How to Wire Up GitHub Actions Instead

The Problem With Letting Netlify Own Your Build Pipeline

Netlify’s Git integration is genuinely great for the first week of a project. You connect a repo, it detects your framework, and deploys happen automatically on every push. That convenience has a ceiling, and most teams hit it faster than they expect.

The ceiling I hit looked like this: our build was passing locally, CI was green, and we pushed to main. Netlify built successfully and deployed. Users started reporting broken interactions on the site — buttons that did nothing, a filter component that threw a runtime error. After two hours of debugging, I found the culprit: Netlify’s build runners had defaulted to Node 16 while we were developing on Node 20. A dependency we’d added was using Array.prototype.toSorted(), which ships in Node 20 but doesn’t exist in 16. Netlify installed the package, compiled it, shipped it, and never said a word about the version mismatch. The build “succeeded.” The build was broken. That’s when I moved builds to GitHub Actions entirely.

Here’s what Netlify’s auto-deploy pipeline actually can’t give you:

  • Node version pinning with a hard guarantee. You can set NODE_VERSION in a .nvmrc or netlify.toml, but if that file drifts or gets misconfigured, Netlify silently falls back to its own default. In GitHub Actions, you declare the exact version in the workflow YAML and the runner enforces it — no fallback, no silent degradation.
  • Test-gated deploys. With Netlify’s Git integration, there is no native way to run your test suite and block a deploy if tests fail. You can bolt on a CI check, but the deploy trigger is separate from the test outcome. In GitHub Actions, the deploy step literally doesn’t run if the test job fails — they’re steps in the same pipeline.
  • Dependency caching. Netlify caches node_modules between builds, but you get zero visibility into whether that cache is stale, how large it is, or when it was last busted. GitHub Actions gives you actions/cache with a cache key you define — usually a hash of your package-lock.json — so you know exactly when a fresh install runs.
  • Reusable workflows across repos. If you manage multiple static sites (a docs site, a marketing site, a component storybook), you can write one deploy workflow and call it from every repo using workflow_call. Netlify’s build config is per-project and non-composable.

The Netlify CLI is what makes this swap practical. Instead of using Netlify’s Git integration, you disable it in the Netlify dashboard under Site configuration → Build & deploy → Continuous deployment (hit “Stop builds”), then trigger deploys manually from GitHub Actions using netlify deploy --prod --dir=dist. You authenticate with a NETLIFY_AUTH_TOKEN stored as a GitHub secret and a NETLIFY_SITE_ID. The workflow controls everything; Netlify just receives the built output.

- name: Deploy to Netlify
  run: netlify deploy --prod --dir=dist --message "Deploy from ${{ github.sha }}"
  env:
    NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
    NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

The honest trade-off: this adds complexity. You now own the build pipeline, which means you’re responsible when something breaks in it. Netlify’s auto-deploy is genuinely zero-maintenance for simple projects — a personal blog, a portfolio site, anything without a real test suite or strict environment requirements. But the moment you have multiple developers, a real dependency tree, and actual users who notice when JavaScript breaks, owning the pipeline pays off immediately. For a broader look at automating your dev workflow end-to-end, see the Ultimate Productivity Guide: Automate Your Workflow in 2026.

What You Need Before Starting

The thing that tripped me up the first time I set this up was leaving Netlify’s built-in Git integration running while also pushing deploys from GitHub Actions. You end up with double deployments, race conditions, and a deploy log that looks like a mess. So the first real prerequisite isn’t installing anything — it’s making sure your Netlify site is either brand new and never connected to a Git repo, or that you’ve already gone into Site Configuration > Build & deploy > Continuous deployment and hit “Disconnect” to unlink it. We’re taking full control of the deploy trigger ourselves. Netlify’s auto-deploy is convenient until you need environment-specific builds, manual approval gates, or pre-deploy test runs — then it just gets in the way.

Install the Netlify CLI globally if you haven’t already:

npm install -g netlify-cli

Current stable is 17.x as of this writing. Run netlify --version after install to confirm. You don’t strictly need the CLI to deploy from Actions — the netlify/actions/cli action handles that in the workflow — but having it locally lets you test deploys manually before you trust a CI pipeline with them. I’ve caught broken build configs this way more than once. Run netlify deploy --dir=dist --prod locally first. If it works from your machine, the Actions workflow will work too.

Now for the two secrets you can’t live without. First: your NETLIFY_AUTH_TOKEN. This is a personal access token, not an OAuth app token. Go to app.netlify.com/user/applications, scroll to “Personal access tokens”, and generate a new one. Name it something meaningful like github-actions-deploy so future-you knows what it’s for when you’re auditing tokens six months from now. Copy it immediately — Netlify won’t show it again. Second: your NETLIFY_SITE_ID. Find it under Site Configuration > General > Site details. It’s the long UUID-looking string labeled “Site ID”. Not the site name, not the subdomain — the actual ID. That distinction matters because the CLI and the deploy API both need the ID, not the human-readable name.

Adding both to GitHub is straightforward but the path trips people up once:

  1. Go to your GitHub repo
  2. Click Settings (the gear icon on the repo, not your profile)
  3. In the left sidebar: Secrets and variables > Actions
  4. Click New repository secret
  5. Name it exactly NETLIFY_AUTH_TOKEN, paste the token value, save
  6. Repeat for NETLIFY_SITE_ID

The secret names are case-sensitive and need to match exactly what you’ll reference in your workflow YAML with ${{ secrets.NETLIFY_AUTH_TOKEN }}. I’ve spent 20 minutes debugging a failed deploy only to find a lowercase letter in a secret name. GitHub won’t warn you — it just passes an empty string to the CLI, which fails silently or throws a generic auth error. Double-check the names before you move on.

One thing nobody mentions in the official docs: if you’re working in a GitHub organization rather than a personal repo, you may need organization-level secrets depending on your org’s policy. If your workflow can’t read the secrets despite them being set correctly, that’s the first place to check — org admins can restrict which repos have access to org-level secrets. For a personal repo this is never an issue, but for team projects on org accounts it bites people regularly.

Step 1 — Disconnect Netlify’s Built-In Git Integration

The first thing you need to do is stop Netlify from treating your repo as a build trigger. By default, every push to your connected branch kicks off a Netlify build — their infrastructure, their build minutes, their timing. Once you wire up GitHub Actions instead, you’ll have two systems racing to deploy the same commit, and they will step on each other. I learned this the hard way when a deploy from GitHub Actions got overwritten 40 seconds later by Netlify’s own stale build finishing.

Go to Site Configuration → Build & deploy → Continuous deployment and click Disconnect Git integration. Netlify will confirm the disconnect and stop listening to your repo’s push events entirely. No more auto-builds from their side. The UI is a little buried — don’t confuse this with “Stop auto publishing,” which is a different toggle that still lets builds run, just holds them in a draft state. You want the full disconnect.

The mental model shift here is important: you want Netlify to be a deploy target, not a build runner. Think of it like S3 — you don’t ask S3 to compile your code, you just push artifacts to it. Netlify is the same after this step. GitHub Actions handles the checkout, the npm install, the npm run build, and then hands the dist/ folder off to Netlify via their CLI or Deploy API. Netlify’s job is CDN distribution, form handling, redirect rules, and edge functions. It does those things well. Building your Node project is not where it adds value.

Here’s what catches people out: do not delete the site. I’ve seen developers nuke the Netlify site thinking they’re “starting fresh with GitHub Actions” — then spend two hours wondering why their netlify.toml redirect rules aren’t working, forgetting those rules are processed by Netlify’s CDN layer, not by their own server. Your _redirects file, your form endpoints, your Netlify Edge Functions — all of that still requires the site to exist and be active. You’re just removing Netlify’s Git-triggered build pipeline, not its runtime infrastructure.

# After disconnecting, verify no build hooks remain active
# Go to: Site Configuration → Build & deploy → Build hooks
# Delete any existing hooks — they can still trigger builds if called
# You'll create a fresh deploy key for GitHub Actions in the next step

One more thing worth doing right now while you’re in the dashboard: note down your Site ID from Site Configuration → General → Site details. You’ll need it as a GitHub Actions secret. Also generate a new Personal Access Token under your Netlify user settings (not the site settings) — scope it to your team, and give it a name like github-actions-deploy so you know exactly what’s using it when you audit tokens six months from now. Netlify’s free tier gives you 500 build minutes/month, but since you’re moving builds to GitHub Actions, those minutes stop mattering — Actions gives you 2,000 free minutes/month on public repos, unlimited on public repos depending on runner type. The math alone makes this swap worth it for most indie projects.

Step 2 — Write the GitHub Actions Workflow File

The file path is non-negotiable

GitHub scans for workflow files specifically at .github/workflows/ relative to your repo root. Not github/workflows/, not .github/workflow/ (singular). I’ve watched developers spend 20 minutes wondering why nothing triggered, and it was a missing dot or a singular folder name. Create the exact path: .github/workflows/deploy.yml. The filename itself can be anything, but I keep it as deploy.yml so it’s obvious at a glance.

Here’s the full working config I use for production deploys. This triggers on every push to main, caches dependencies, builds the project, and ships to Netlify:

name: Deploy to Netlify

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

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

      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Deploy to Netlify
        run: |
          npx netlify-cli deploy \
            ${{ github.event_name == 'push' && '--prod' || '' }} \
            --dir=dist \
            --auth=$NETLIFY_AUTH_TOKEN \
            --site=$NETLIFY_SITE_ID
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

The deploy command doing the heavy lifting is netlify deploy --prod --dir=dist --auth=$NETLIFY_AUTH_TOKEN --site=$NETLIFY_SITE_ID. The --prod flag is what promotes the deploy to your live URL. Without it, Netlify spins up a temporary preview URL — which is exactly what you want for pull requests. I use the inline conditional above to toggle --prod based on whether we’re on a push event or a PR, so the same workflow file handles both cases without duplication. One thing to double-check: make sure --dir matches your actual build output folder. Vite outputs to dist, Create React App outputs to build, Next.js static export goes to out. Getting this wrong gives you a cryptically empty deploy.

The caching step is worth your attention

The actions/cache@v4 step with ${{ hashFiles('**/package-lock.json') }} as the cache key is the single biggest speed win in this whole setup. The hash changes only when package-lock.json changes, so on every routine push where you haven’t touched dependencies, the runner pulls the cached modules from storage instead of re-downloading them. This cut about 40 seconds off my build time on a mid-sized project with around 800 packages. The restore-keys fallback is a soft match — if the exact hash doesn’t exist (e.g., first run after adding a package), it grabs the closest previous cache instead of starting cold. Don’t skip this step; it compounds over dozens of daily deploys.

Preview deploys on PRs are genuinely useful — but you have to wire them up

When the workflow runs on a pull request and --prod is absent, Netlify returns a unique preview URL in the CLI output. That URL is ephemeral and scoped to that deploy. The workflow config above handles the deploy itself, but the preview URL just sits in your Actions logs unless you do something with it. To post it back as a PR comment, add a step after the deploy that captures the URL from the CLI output and uses actions/github-script to write it as a comment. It takes maybe 10 extra lines and makes your PR review process visibly better — reviewers can click a link instead of pulling the branch locally. I’ll cover that in the next step, but keep it in mind when you’re structuring this workflow file.

Full Annotated deploy.yml Example

The Complete deploy.yml — Every Line Explained

Here’s the full file I actually use. Not a skeleton. Not a “simplified example.” The real thing, with comments explaining the decisions that aren’t obvious from the GitHub Actions docs alone.

name: Test and Deploy

# Trigger on every push and on pull requests targeting main.
# PRs will run tests but will NOT deploy (the deploy job checks the branch).
on:
  push:
    branches:
      - '**'
  pull_request:
    branches:
      - main

jobs:
  # ─────────────────────────────────────────────
  # JOB 1: test
  # Runs on every push, every branch, every time.
  # If this job fails, the deploy job never starts.
  # That's the whole point of splitting these into two jobs.
  # ─────────────────────────────────────────────
  test:
    runs-on: ubuntu-latest

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

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          # cache: 'npm' saves ~30-40 seconds on subsequent runs
          # by caching node_modules between workflow runs
          cache: 'npm'

      - name: Install dependencies
        run: npm ci
        # Use `npm ci` not `npm install`.
        # ci does a clean install from package-lock.json — no surprises.

      - name: Run tests
        run: npm test
        # If this exits with a non-zero code, the whole workflow stops.
        # The deploy job below has `needs: test`, so it never even starts.

      - name: Build site
        run: npm run build
        # Build here, not in the deploy job.
        # If the build breaks, we catch it before touching Netlify.

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: ./dist
          # Change `./dist` to wherever your build tool outputs files.
          # Vite → dist, Next.js static export → out, Gatsby → public
          retention-days: 1
          # 1 day is enough. We only need this artifact for the deploy job
          # running seconds later. No need to pay for longer storage.

  # ─────────────────────────────────────────────
  # JOB 2: deploy
  # Only runs if the `test` job above passed.
  # Only deploys to PRODUCTION if we're on the main branch.
  # Every other branch gets a Netlify draft deploy (preview URL).
  # ─────────────────────────────────────────────
  deploy:
    runs-on: ubuntu-latest
    needs: test
    # `needs: test` is the dependency declaration.
    # Without this, both jobs run in parallel and deploy
    # could succeed even when tests are failing.

    # Set secrets at the JOB level, not the step level.
    # Anything in `env` here is available to ALL steps in this job.
    # If you put it inside a single step's `env`, other steps can't see it
    # and you'll end up copy-pasting the same env block everywhere.
    env:
      NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
      NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: ./dist
          # Pulling the artifact from the test job.
          # We're not checking out the repo again or rebuilding.
          # Same files that passed tests are the files that get deployed.

      - name: Install Netlify CLI
        run: npm install -g netlify-cli@latest
        # Pinning to `latest` here is intentional for CLI tools.
        # The Netlify CLI version rarely introduces breaking changes,
        # and you want security patches without manual bumping.
        # If you're paranoid, pin to a specific version like [email protected]

      - name: Deploy preview (all branches except main)
        # This runs on every branch push EXCEPT main.
        # Netlify creates a unique draft URL — great for PR reviews.
        if: github.ref != 'refs/heads/main'
        run: |
          netlify deploy \
            --dir=./dist \
            --message="Preview: ${{ github.sha }}"
        # No `--prod` flag = draft deploy.
        # Netlify returns a unique URL you can paste into your PR.

      - name: Deploy to production (main branch only)
        # THE CRITICAL GUARD. Without this condition, every branch push
        # that passes tests will overwrite your production site.
        # I've seen this bite teams who assumed Netlify's branch settings
        # would protect them. They don't, because we bypassed those settings
        # entirely by using the CLI directly.
        if: github.ref == 'refs/heads/main'
        run: |
          netlify deploy \
            --prod \
            --dir=./dist \
            --message="Production deploy: ${{ github.sha }}"
        # `--prod` promotes the deploy to the main production URL.
        # The `--message` flag attaches the commit SHA to the deploy log
        # in the Netlify dashboard — makes rollbacks much easier to identify.

The needs: test line on the deploy job is doing the real work here. Without it, GitHub Actions runs jobs in parallel by default. That means your deploy could go out while tests are still running — or worse, while they’re failing. I’ve watched this happen on a team that copied a “simplified” workflow from a blog post that had stripped out the job dependency. A broken build hit production on a Friday afternoon. needs: test is a one-line fix that eliminates that entire class of problem.

The environment variable scoping decision trips people up. The instinct is to put NETLIFY_AUTH_TOKEN inside the specific step that calls netlify deploy. That works — until you add a second step that also needs it, like a Netlify status check or a post-deploy smoke test. If you scoped it to a step, you now have to duplicate the env block. Setting it at the job level once means every step in that job inherits it automatically. The secret stays secret either way — GitHub Actions masks it in logs regardless of where you declare it.

The if: github.ref == 'refs/heads/main' condition on the production deploy step is non-negotiable. Here’s the scenario without it: a developer pushes a feature branch, tests pass, the deploy job runs, netlify deploy --prod executes, and your production site now contains half-finished work. The Netlify dashboard branch deploy settings won’t save you here because you’ve bypassed the Git integration entirely — you’re calling the CLI directly. The guard condition is the only thing standing between a feature branch push and a production deploy. The preview deploy step uses != to catch everything that isn’t main, giving you draft preview URLs for every branch automatically, which is actually a better review workflow than anything Netlify’s built-in Git integration provides.

One thing I didn’t expect: the artifact upload/download between jobs adds about 10-15 seconds to total pipeline time, but it’s worth it. The alternative — rebuilding in the deploy job — means your deployed files are technically different from the files your tests ran against. They’ll almost certainly be identical, but “almost certainly” is not a thing you want in a deployment pipeline. Build once, test those exact files, deploy those exact files.

Step 3 — Handle Deploy Previews for Pull Requests

The Workflow Trigger You Actually Want

Deploy previews are where this whole setup pays off. Instead of asking a reviewer to clone a branch and run the site locally, you give them a live URL in the PR itself. The trigger for this is a separate workflow file — I keep mine as .github/workflows/preview.yml to keep it clearly distinct from the production deploy workflow.

on:
  pull_request:
    types: [opened, synchronize]

synchronize fires every time a new commit is pushed to the branch. That’s the one people forget. Without it, you get a preview URL on PR open and then it never updates as the author pushes fixes. Add both types and every push to the PR branch rebuilds the preview automatically.

The Deploy Command That Creates a Draft

The difference between a production deploy and a draft deploy is one flag. Drop --prod and Netlify creates a Draft Deploy instead — isolated, not attached to your live site’s URL, and scoped to that specific deploy ID. The URL format is predictable: https://deploy-preview-123--yoursite.netlify.app where 123 is the PR number. Here’s the full deploy step:

- name: Deploy Preview to Netlify
  id: netlify_deploy
  run: |
    OUTPUT=$(netlify deploy \
      --dir=./dist \
      --site=${{ secrets.NETLIFY_SITE_ID }} \
      --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} \
      --json)
    PREVIEW_URL=$(echo $OUTPUT | jq -r '.deploy_url')
    echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT

The --json flag is what makes parsing the URL reliable. Without it you’re scraping human-readable CLI output, which Netlify has changed formatting on before. Parse JSON, not text. The deploy_url field gives you the exact draft URL for that specific deploy.

Posting the URL Back to the PR

This is the part that makes reviewers actually appreciate the setup. Use actions/github-script to post a comment with the preview URL. The thing that caught me off guard the first time I set this up was that the comment step silently passed in the workflow UI but the comment never appeared — turned out to be a 403 the logs weren’t surfacing clearly. More on that in a second. Here’s the working script block:

- name: Comment Preview URL on PR
  uses: actions/github-script@v7
  with:
    script: |
      const previewUrl = '${{ steps.netlify_deploy.outputs.preview_url }}';
      const body = `## 🚀 Deploy Preview Ready\n\n**URL:** ${previewUrl}\n\n*Preview updates automatically on every push to this branch.*`;

      // Find existing bot comment to avoid spamming
      const comments = await github.rest.issues.listComments({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.issue.number,
      });

      const existing = comments.data.find(c =>
        c.user.type === 'Bot' && c.body.includes('Deploy Preview Ready')
      );

      if (existing) {
        await github.rest.issues.updateComment({
          owner: context.repo.owner,
          repo: context.repo.repo,
          comment_id: existing.id,
          body,
        });
      } else {
        await github.rest.issues.createComment({
          owner: context.repo.owner,
          repo: context.repo.repo,
          issue_number: context.issue.number,
          body,
        });
      }

The find-and-update logic is worth keeping. Without it, every push to the PR creates a new comment and you end up with ten identical-looking comments cluttering the PR thread. Update the existing one instead.

The Permissions Gotcha That Will 403 You

Here’s the thing that gets everyone the first time: by default, the GITHUB_TOKEN in Actions has read-only permissions for pull requests. Posting a comment requires write access. If your workflow’s permissions block is missing or doesn’t explicitly grant pull-requests: write, the comment step will fail with a 403. Add this at the top level of your workflow file, not inside the job:

permissions:
  contents: read
  pull-requests: write

I’d also recommend keeping contents: read explicit rather than letting it default to write. Principle of least privilege — your preview workflow doesn’t need to push anything, so don’t give it that power. If you’re in an org that uses a fine-grained PAT instead of GITHUB_TOKEN, double-check that the token has pull_request: write scope on the specific repo. The error message you’ll get back from the GitHub API is generic enough that it takes a few minutes to trace back to the permissions block the first time you hit it.

Gotchas I Hit That the Docs Don’t Warn You About

The `–dir` Flag Will Silently Betray You

The thing that caught me off guard first was the --dir flag behavior. If you pass the wrong output directory, the CLI doesn’t throw an error — it deploys an empty site and exits with code 0. Your GitHub Actions step goes green. Your Slack notification says “deployed”. And your actual site is blank. I spent 40 minutes debugging a production deploy before I realized Hugo outputs to public/, not dist/. Astro defaults to dist/, Next.js static export goes to out/, and Gatsby uses public/ like Hugo. Get this wrong and you’ll never see an error message — just an empty deploy URL staring at you.

# Astro
netlify deploy --prod --dir=dist

# Next.js (static export — next.config.js must have output: 'export')
netlify deploy --prod --dir=out

# Hugo
netlify deploy --prod --dir=public

# Gatsby
netlify deploy --prod --dir=public

Always add a sanity check step before your deploy step in the workflow. Something this dumb saves real pain:

- name: Verify build output exists
  run: |
    if [ ! -d "dist" ]; then
      echo "Build output directory not found. Aborting."
      exit 1
    fi
    echo "Files in dist: $(ls dist | wc -l)"

Your `_redirects` and `netlify.toml` Still Work — Don’t Touch Them

I assumed switching to CLI deploys meant losing all my redirect rules. Nope. The CLI reads both _redirects (if it’s in your deploy directory) and netlify.toml from the project root before uploading. Your SPA fallback rule, your custom headers, your redirect chains — they all upload correctly. The one thing that tripped me up: if _redirects lives in your repo root but your build doesn’t copy it to dist/, it won’t be included. Make sure your build pipeline moves it over, or just use netlify.toml instead, which gets picked up from the root regardless.

Code 0 Doesn’t Mean Everything Deployed

The CLI exits with code 0 on some partial failures — asset upload timeouts being the main culprit. I’ve seen deploys where 3 of 200 files failed to upload, the CLI reported success, and the live site was serving stale cached versions of those files. Always capture the deploy URL from the CLI output and do at least a basic HTTP check in your workflow before declaring victory:

- name: Deploy to Netlify
  id: netlify_deploy
  run: |
    DEPLOY_OUTPUT=$(netlify deploy --prod --dir=dist --json)
    echo "deploy_url=$(echo $DEPLOY_OUTPUT | jq -r '.deploy_url')" >> $GITHUB_OUTPUT

- name: Smoke test deploy URL
  run: |
    STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ steps.netlify_deploy.outputs.deploy_url }}")
    if [ "$STATUS" != "200" ]; then
      echo "Deploy URL returned $STATUS. Something went wrong."
      exit 1
    fi

Netlify Functions Get Silently Ignored Without `–functions`

If you’re using Netlify Functions and you don’t pass --functions to the CLI, they just don’t upload. No warning. The site deploys fine, your function endpoints 404, and nothing in the output tells you why. The flag you need:

netlify deploy --prod --dir=dist --functions=functions

That assumes your functions live in a functions/ directory at the repo root, which is the convention. If you’ve configured a custom path in netlify.toml under [functions] directory, the CLI will respect that — but only if you’re also passing --functions. Skipping the flag skips functions entirely regardless of your config file. I confirmed this the hard way on a project with a contact form handler.

Monorepos Will Hit Netlify’s API Rate Limits Fast

If you have a monorepo where multiple packages each deploy to their own Netlify site, and all of them trigger on the same push to main, you’ll start seeing 429 responses from Netlify’s API within a few deploys. The fix is adding concurrency groups in your workflows so only one deploy runs at a time per Netlify site token:

jobs:
  deploy:
    runs-on: ubuntu-latest
    concurrency:
      group: netlify-deploy-${{ github.ref }}
      cancel-in-progress: false

Setting cancel-in-progress: false is important here — you want deploys to queue, not cancel. If you cancel mid-deploy you can end up with a partial upload on Netlify’s side. For a monorepo with five packages, I’d also stagger deploys using needs: dependencies between jobs so they chain rather than fan out simultaneously. It’s slower, but it’s reliable, and Netlify’s free and Pro tier both have API limits that are easy to underestimate when everything deploys at once.

When to Just Use Netlify’s Built-In Deploy Instead

Skip the Workflow File If You Don’t Actually Need It

Here’s the honest take: the GitHub Actions + Netlify setup I walked through earlier is genuinely useful, but it’s also about 80 lines of YAML, a stored API token, a site ID environment variable, and at least one afternoon of debugging deploys that work locally but fail in CI. If you’re shipping a personal blog or a portfolio site with zero tests, that tradeoff is just not worth making. Netlify’s native Git integration takes about 90 seconds to configure and it works. Connecting your repo, setting your build command, and hitting deploy — that’s the entire setup. I’ve watched developers spend three hours wiring up Actions for a site that publishes twice a month. Don’t be that developer.

The build plugin situation is the gotcha that catches people most often. Netlify’s Essential Next.js plugin (@netlify/plugin-nextjs) does a lot of work behind the scenes — it handles SSR function bundling, image optimization routing, and incremental static regeneration in ways that only work when Netlify’s own build system runs the process. If you yank the build out of Netlify and push it via the CLI from GitHub Actions, the plugin never runs. You’ll get a static export that’s missing half the functionality and no error message that clearly tells you why. I learned this specifically with a Next.js 13 app where dynamic routes were silently 404-ing in production. The fix was just… letting Netlify build it.

Same story with a few other first-party plugins:

  • @netlify/plugin-lighthouse — runs post-deploy audits, only triggers in Netlify’s build pipeline
  • netlify-plugin-submit-sitemap — pings search engines after a successful Netlify build event
  • Any plugin using Netlify’s onSuccess or onEnd build lifecycle hooks — those hooks don’t exist in a CLI deploy

If your netlify.toml has a [[plugins]] block, double-check whether you can actually afford to move the build out of Netlify before you do it.

The multi-platform team angle is also real and underappreciated. GitHub Actions is GitHub-only. If your company runs on GitLab (extremely common in European enterprises and regulated industries) or Bitbucket (still widely used in teams already on Atlassian), Netlify’s native integration handles all three cleanly. The deploy hooks, branch deploy previews, and collaborative deploy notifications all work regardless of your Git host. Netlify supports webhook-based deploys from any source, so you’re not locked in. GitHub Actions is a great tool that happens to require GitHub — if that’s not your stack, the entire premise of this guide doesn’t apply to you.

My rough mental checklist before I reach for the custom Actions workflow:

  1. Do I have tests that need to pass before a deploy? If no, stop here.
  2. Am I running a build step that Netlify can’t do natively? If no, stop here.
  3. Do I need to coordinate deploys across multiple services or repos? If no, stop here.
  4. Is my whole team already on GitHub and comfortable with Actions? If no, reconsider.

If you hit “yes” on at least two of those, the custom workflow earns its complexity. Otherwise you’re adding infrastructure to feel productive rather than because the project actually needs it. Netlify’s built-in deploy is genuinely good — it’s not a beginner shortcut, it’s just the right tool for a large category of projects.

Checking Your Setup Is Actually Working

Push First, Then Watch Everything Light Up

The first real sanity check is dead simple: push any commit to main and immediately open the Actions tab in your GitHub repo. You should see the workflow appear within a few seconds — not minutes. If it takes longer than 30 seconds to show up, your on: push trigger isn’t configured correctly or you’ve got a branch filter mismatch. I’ve caught this twice by accidentally writing branches: [master] when the repo was using main. The workflow just silently does nothing.

# Quick commit to trigger the pipeline
git commit --allow-empty -m "chore: trigger CI check"
git push origin main

An empty commit is your best friend here. No need to fake a file change just to test the pipeline.

Once the workflow runs, jump into Netlify’s dashboard → Deploys. This is the confirmation most tutorials skip over. You should see your deploy listed with “API” shown as the deploy method — not “Git”. If you still see “Git” on new deploys, Netlify is still building on its own, which means you’ve got two systems fighting to deploy your site. You need to disable Netlify’s Git integration under Site Settings → Build & deploy → Continuous deployment → stop builds. The “API” label is the only reliable proof that your GitHub Actions workflow is the one actually shipping code.

Testing PR Previews — Don’t Skip This

Open a new branch, push a trivial change, and open a pull request. Your workflow should pick this up if you’ve got on: pull_request in your trigger config. Within a minute or two, you should see a bot comment on the PR with a preview URL that looks like https://deploy-preview-42--your-site-name.netlify.app. If that comment never appears, check two things: first, confirm your workflow has pull-requests: write in its permissions block, and second, verify the Netlify CLI deploy step includes the --alias flag tied to the PR number.

- name: Deploy Preview
  run: |
    netlify deploy \
      --dir=./build \
      --alias=deploy-preview-${{ github.event.pull_request.number }}
  env:
    NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
    NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

The thing that caught me off guard the first time: Netlify’s preview URL structure requires the alias to exactly match what Netlify expects. If you’re getting deploys but no preview URL resolves, check the deploy logs in Netlify’s dashboard for the exact alias it registered.

Break Something on Purpose — This Is the Whole Point

This step is non-negotiable. Go into your test suite and introduce a deliberate failure. Something obvious:

// jest test — intentionally broken
test('math works', () => {
  expect(2 + 2).toBe(5); // this will fail
});

Push that to a branch and watch the Actions tab. Your test job should fail and turn red. Now look at the deploy job — it should show as skipped, not failed, not running. Skipped. That distinction matters. If the deploy job still runs and just happens to fail because the tests didn’t produce artifacts, your job dependency is wrong. The deploy job must have needs: test in its config and your test job must actually exit with a non-zero code on failure. A lot of people set up the dependency but their test script swallows errors and exits 0, which means broken code ships anyway.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm ci
      - run: npm test  # must exit non-zero on failure

  deploy:
    needs: test  # this is what blocks the deploy
    runs-on: ubuntu-latest
    steps:
      - run: netlify deploy --prod ...

Until you’ve seen a broken test actually block a deploy with your own eyes, you don’t really know the pipeline works. Undo the broken test after you’ve confirmed it. That verification run — watching the skipped deploy job after a test failure — is the only honest confirmation that the whole setup is doing what you built it to do.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Eric Woo

Written by Eric Woo

Lead AI Engineer & SaaS Strategist

Eric is a seasoned software architect specializing in LLM orchestration and autonomous agent systems. With over 15 years in Silicon Valley, he now focuses on scaling AI-first applications.

Leave a Comment