The Problem With Netlify’s Default Git Integration
Netlify’s built-in Git integration is genuinely great — right up until your team grows past one person or your site does anything more interesting than render HTML. The default behavior is simple: push to main, Netlify picks it up, your site is live in 60–90 seconds. No friction. No gates. That’s also exactly the problem.
I learned this the hard way on a client project. Someone pushed a config change late at night — a one-line edit to netlify.toml that had a malformed redirect rule. Netlify saw the push, built it, deployed it. No tests ran. No lint check fired. The redirect loop broke the contact form for about four hours before anyone noticed the next morning. The frustrating part isn’t that the deploy happened — it’s that there was nothing in the way to catch it. Netlify’s default pipeline has exactly zero hooks for “run this before you go live.”
The structural issue is that Netlify’s auto-publish model conflates two things that should be separate: building and deploying. Every commit to your production branch is treated as an implicit “yes, ship this.” If you want to run a test suite, validate environment variables, check bundle size regressions, or gate deploys based on branch logic, the built-in integration just doesn’t have the surface area for it. You can add a build command in your netlify.toml, but that runs inside Netlify’s own build environment — you don’t control the runner, you can’t cache dependencies the way you want, and you definitely can’t easily coordinate with external services or secrets that live in GitHub.
# What your netlify.toml probably looks like right now
[build]
command = "npm run build"
publish = "dist"
# The problem: this runs inside Netlify's CI, not yours
# You have limited control over the environment, caching, and pre-deploy logic
What actually works — and what this guide walks through — is turning off Netlify’s auto-publish entirely and replacing it with GitHub Actions as the deployment controller. GitHub Actions handles everything: install, lint, test, build, and then calls Netlify’s CLI or deploy API only when all those steps pass. Netlify becomes a pure hosting target, not a CI/CD system. You get full control of the runner, your own caching strategy, branch-specific deploy logic, and a clear audit trail in your pull requests showing exactly which checks passed before anything went live.
The trade-off is real: you’re adding setup complexity upfront. A fresh Netlify site connected to GitHub takes about three minutes to configure. The GitHub Actions approach takes maybe 30–45 minutes the first time, and you’ll need to manage two sets of configuration — your .github/workflows directory and your Netlify site settings. But that investment pays back immediately on any team bigger than a solo side project. For a broader look at workflow automation tools that pair well with this kind of setup, check out our guide: Ultimate Productivity Guide: Automate Your Workflow in 2026.
What You Need Before Starting
The thing that trips people up most isn’t the GitHub Actions YAML — it’s showing up to write that config without the two Netlify credentials you actually need. I’ve watched developers spend 40 minutes debugging a failed workflow only to realize they either grabbed the wrong site ID or used a personal access token scoped to the wrong team. Get these values before you write a single line of workflow config.
You need a Netlify account with at least one site created. It doesn’t need to be connected to GitHub through Netlify’s built-in Git integration — in fact, for this setup, you don’t want that auto-deploy connection active, or you’ll end up with duplicate deploys every time you push. Create the site, but leave the build settings blank or set the build command to nothing. We’re handing that responsibility entirely to GitHub Actions.
Your GitHub repo can hold basically anything Netlify can process: Next.js, Gatsby, Astro, Hugo, SvelteKit, or a folder of raw HTML files. The framework doesn’t matter here because we’re going to run the build step ourselves inside the Action and just hand Netlify the finished dist (or out, or public — whatever your framework outputs). Install the Netlify CLI locally right now and run a manual deploy first. That single step saves you from debugging in CI:
npm install -g netlify-cli
netlify deploy --dir=dist --prod
If that command fails on your machine, it’ll fail in the Action too. Fix it locally first. The --dir flag should point to your build output directory, not your project root. First-timers constantly pass the wrong path here and end up deploying their source files instead of the built output.
Now grab your two secrets. The NETLIFY_AUTH_TOKEN lives under User Settings → Applications → Personal access tokens. Generate a new one — don’t reuse tokens across projects. If that token leaks, you want to be able to revoke it without breaking every other site you manage. The NETLIFY_SITE_ID is under Site Settings → General → Site details, listed as “Site ID” — it looks like a UUID (a1b2c3d4-e5f6-...). Not the site name, not the Netlify subdomain. The UUID.
- NETLIFY_AUTH_TOKEN — User Settings → Applications → Personal access tokens → New access token
- NETLIFY_SITE_ID — Site Settings → General → Site details → Site ID (the UUID format)
Store both as GitHub Actions secrets, not environment variables in your repo. Go to your GitHub repo → Settings → Secrets and variables → Actions → New repository secret. Name them exactly NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID — the workflow config we’ll build later references those exact names. If you’re on a GitHub organization, you can promote these to org-level secrets later so multiple repos can share them, but start repo-scoped until you’re confident the flow works.
Step 1: Disconnect Netlify’s Auto-Deploy From GitHub
The thing that trips up most people first time round: if you connect your GitHub repo to Netlify during the initial setup — which the onboarding flow basically pushes you to do — Netlify starts watching every push to your main branch and deploying automatically. The moment you also add a GitHub Actions workflow that calls the Netlify API to deploy, you’ve created a race condition. Two deploys kick off for every single push, they can conflict on which one “wins” the production URL, and your deploy logs become a mess to debug. Fix this before you write a single line of Actions YAML.
Head to Site Settings → Build & Deploy → Continuous Deployment and hit the Disconnect button next to your linked GitHub repo. Netlify will ask you to confirm. Do it. From this point on, Netlify has no idea your repo exists — it’s a passive recipient waiting for deploy payloads, not an active watcher. Your Actions workflow becomes the single source of truth for when and how deploys happen.
# After disconnecting, you can verify via the Netlify CLI:
netlify status
# Should show your site but no linked repo under "Current site"
Now, the honest trade-off with full disconnection: you lose Netlify’s native deploy previews for pull requests. That’s the feature where every PR automatically gets its own deploy-preview-123--yoursite.netlify.app URL. Netlify generates those by watching the repo directly, and once you disconnect, that pipeline is gone. If your team relies on those preview links in PR reviews — and honestly, they’re genuinely useful — there’s a softer option. Instead of disconnecting entirely, go to the same settings page and set Stop auto publishing. Netlify will still build on every push, but it won’t promote those builds to production. Your Actions workflow then handles the production promotion explicitly using netlify deploy --prod. You get previews, you get controlled production deploys. The downside is you’re still burning Netlify’s build minutes on those background builds, which matters if you’re on the Starter tier (300 minutes/month as of their current pricing).
The gotcha I hit personally: if you full-disconnect and then months later decide you want native PR previews back, re-linking the repo isn’t always as clean as the docs make it sound. Netlify sometimes creates a duplicate webhook in your GitHub repo settings rather than reusing the old one, so you end up with two webhooks pointing at Netlify and have to manually clean up the stale one from GitHub repo → Settings → Webhooks. It’s not catastrophic, just annoying. Check your webhooks list before and after if you re-link.
- Full disconnect — cleanest setup for Actions-controlled deploys, no wasted build minutes, but you lose native PR previews
- Stop auto publishing — keeps PR previews alive, but you’re paying build minutes for every push Netlify still processes in the background
- Do nothing (skip this step entirely) — double deploys, unpredictable production state, don’t do this
My default recommendation: disconnect fully, then implement deploy previews yourself in Actions using netlify deploy without the --prod flag on PR events, and post the preview URL back to the PR as a comment using the GitHub API. More setup upfront, zero dependency on Netlify’s repo watcher, and you own the whole pipeline. That approach is exactly what the rest of this guide walks through.
Step 2: Store Your Secrets in GitHub
Get Your Secrets Into GitHub Before You Touch the Workflow File
The first mistake I see junior devs make isn’t in the YAML — it’s committing credentials directly into the workflow file, then scrambling to rotate them when they realize the repo is public. Even in a private repo, hardcoding tokens is a habit that will eventually burn you. You’ll make the repo public for a portfolio piece, fork it into a client project, or just forget the token is sitting there six months later. Do it right from the start: GitHub’s encrypted secrets storage is free, already available in every repo, and takes about 90 seconds to set up.
Navigate to your repo, click Settings, then scroll down to Secrets and variables in the left sidebar and hit Actions. From there, click New repository secret. You’ll need to add two secrets separately:
NETLIFY_AUTH_TOKEN— your personal access token from Netlify (get it at User Settings > Applications > Personal access tokens)NETLIFY_SITE_ID— the API ID of your specific site (found under Site configuration > Site details > Site ID)
These are two different things and it tripped me up the first time. The auth token authenticates you as a Netlify user — it has access to every site in your account. The site ID tells Netlify which site to deploy to. Keep them separate because that separation matters for auditing: if a token leaks, you want to know exactly what it had access to.
Once stored, you reference them in your workflow YAML like this — and nowhere in the file should you see the actual values:
- name: Deploy to Netlify
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
GitHub redacts these values in logs automatically. If you accidentally echo $NETLIFY_AUTH_TOKEN in a run step, you’ll see *** in the output. That’s a nice safety net, but don’t rely on it as your primary protection — it’s a fallback, not a strategy.
If you’re managing deployments for multiple Netlify sites from the same GitHub organization, repo-level secrets get messy fast. Switch to environment-level secrets instead. Go to Settings > Environments, create an environment per site (I use names like production-marketing or staging-docs), and store the site-specific NETLIFY_SITE_ID at the environment level while keeping a single NETLIFY_AUTH_TOKEN at the repo level. That way one token handles auth across all deployments, but each environment only exposes the correct site ID to the job that needs it. It also lets you add protection rules — like requiring a manual approval before anything deploys to production — which is genuinely useful once you have more than one person merging PRs.
Step 3: Write the GitHub Actions Workflow File
Create the workflow file that actually deploys
The thing that caught me off guard the first time I set this up — the --prod flag. Without it, every single Netlify CLI deploy creates a draft URL, not a live production deploy. Your logs show green, Slack says “deploy successful,” and nothing on your actual site changed. You’ll spend 20 minutes wondering why the cache is stuck before you realize the deploy never touched production. Add --prod. Don’t forget it.
Create the file at .github/workflows/deploy.yml from your repo root. GitHub picks up anything inside .github/workflows/ automatically — no registration step, no UI toggle. Here’s the full working workflow I use for Vite-based static sites, and I’ll walk through the non-obvious parts afterward:
name: Deploy to Netlify
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Cache node_modules
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build site
run: npm run build
- name: Deploy to Netlify
run: |
npx netlify-cli deploy \
--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 cache step is one I skipped early on and regretted immediately. Without actions/cache@v3, npm install runs from scratch on every push. If your project has 200+ packages — which is basically any React or Vue project — that’s 45-90 seconds of pure network time on every deploy. With caching keyed to your package-lock.json hash, warm cache hits bring that down to under 5 seconds. The cache only invalidates when you actually change your dependencies. The ~/.npm path caches npm’s download cache specifically; if you’re using node_modules directly you’d change that path, but the npm cache approach is more reliable across environments.
The deploy command itself uses npx netlify-cli rather than installing it as a dev dependency. I switched to this because it avoids versioning the CLI in your repo while still getting a recent build. If you need a pinned version for reproducibility, add netlify-cli to your devDependencies and call it via ./node_modules/.bin/netlify instead. The --dir=dist flag assumes your build output goes to dist/ — Vite does this by default. If you’re on Create React App, swap that for --dir=build.
A note on the jobs structure
I kept install, test, build, and deploy as steps inside one job rather than splitting them into separate jobs. Separate jobs give you better visual separation in the GitHub UI and let you run things in parallel, but they each spin up a fresh runner — meaning you’d need to re-cache or pass artifacts between jobs using actions/upload-artifact. For a simple static site pipeline, that overhead isn’t worth it. One job, sequential steps, shared filesystem. Faster to write, easier to debug when something breaks.
- The
on.push.brancheskey restricts deploys tomainonly — pushes to feature branches won’t trigger this workflow at all - If you want draft deploy previews on PRs, you’d write a second workflow file without
--prodthat triggers onpull_requestevents - The
NETLIFY_AUTH_TOKENandNETLIFY_SITE_IDvalues come from GitHub repo secrets — never hardcode these, and never put them in env files that get committed npm ciinstead ofnpm install— this is non-negotiable in CI. It installs exactly what’s in your lockfile, fails if the lockfile is out of sync, and is faster because it skips the dependency resolution step
One real gotcha I hit: if your npm test script exits with code 0 even when there are no tests (common in scaffolded projects), the deploy will still run even if your test setup is broken. Run npm test -- --passWithNoTests if you’re using Jest, or add a real test before you consider this pipeline “protected.” A green CI badge on a repo with no tests is just theater.
The Workflow YAML, Annotated
Here’s the full workflow file I landed on after a few iterations. Read through the inline comments — I’ve flagged the decisions that aren’t obvious and would have cost me time if I hadn’t known them upfront.
name: Build and Deploy
on:
push:
branches: [main, develop] # Watch both branches
pull_request:
branches: [main] # Also trigger on PRs targeting main
# Set NODE_VERSION once here. When you need to bump from 18 to 20,
# this is the only line you touch. I've burned time hunting down
# 3 separate `node-version: 18` entries in workflows before — never again.
env:
NODE_VERSION: '20'
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest # See note below about why this specifically
steps:
# Standard checkout — depth 1 is fine for static builds,
# but if your build script does git log for versioning, drop this flag.
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
# Cache node_modules based on package-lock.json hash.
# Without this, you're reinstalling ~300MB of deps on every run.
# On a free GitHub Actions plan, this alone cuts build time by 40-60s.
- 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: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }} # Pulls from the env block at top
- name: Install dependencies
run: npm ci # ci instead of install — faster, deterministic, respects lockfile
# Run your tests on EVERY push and PR, no conditions.
# You want this to fail loudly on feature branches too.
- name: Run tests
run: npm test
- name: Build site
run: npm run build # Adjust to your actual build command
# The key conditional: only deploy when we're on main.
# Without this guard, every PR merge AND every direct push to any branch
# would trigger a production deploy. That's how you accidentally ship
# a half-finished feature at 11pm on a Friday.
- name: Deploy to Netlify
if: github.ref == 'refs/heads/main'
uses: netlify/actions/cli@master
with:
args: deploy --dir=dist --prod # Change dist to your actual output folder
env:
NETLIFY_AUTH_TOKEN: ${{ env.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ env.NETLIFY_SITE_ID }}
The ubuntu-latest runner isn’t arbitrary. Netlify’s own build infrastructure runs on Ubuntu, so if you pick windows-latest or macos-latest, you risk path separator issues, case-sensitivity mismatches, and shell script failures that only show up in CI. I switched a client’s pipeline from macOS runners to Ubuntu-latest and immediately fixed a postcss plugin that was silently producing different output on case-insensitive macOS. Ubuntu-latest means you’re testing in an environment close enough to production that you’ll catch real failures instead of CI-only phantoms.
The env block at the top of the file is a habit I’d push any junior to form early. When Node 18 hits end-of-life and you need to bump the whole project to Node 20, you change one line. The alternative — searching through a YAML file for three scattered node-version references — is how version drift happens. Same principle applies to any value you’d otherwise hardcode: build output directory, API endpoint slugs, tool versions. Put them at the top, reference them with ${{ env.VARIABLE }} everywhere else.
The if: github.ref == 'refs/heads/main' condition on the deploy step is the most important guard in this whole file. Without it, the workflow structure looks safe until you realize that a push to your feature/new-header branch also matches on: push — and if the deploy step has no condition, it fires. I’ve seen teams accidentally deploy broken feature branches to production because they forgot this. The pattern is simple: run tests and builds on everything, deploy only from main. PRs get full CI validation without any risk of touching production.
One gotcha that isn’t documented clearly: the netlify/actions/cli@master action pins to master, which sounds risky but is how Netlify officially distributes it. If you want determinism, check the releases tab on their repo and pin to a specific SHA. Also, --prod in the deploy args is what makes it a production deploy rather than a preview deploy. Drop --prod and you’ll get a unique preview URL per deploy — useful if you want to also add a step that comments the preview URL back on a PR, which Netlify’s own GitHub integration handles automatically but this manual approach gives you full control over.
Step 4: Handle Deploy Previews for Pull Requests
Don’t Throw Away Deploy Previews
Deploy previews are genuinely the feature that makes Netlify worth using over a plain S3 bucket. Every PR gets its own live URL, your designer can click around without pulling the branch locally, and stakeholders can leave feedback before anything touches production. I’ve watched teams ditch this entire workflow the moment they locked down their production pipeline — and then quietly suffer for it. You don’t have to choose between controlled production deploys and having previews. You can keep both.
Option A: Split the Duties Between Netlify and GitHub Actions
The cleanest setup for most teams: let Netlify handle PR previews natively (re-enable the GitHub integration in your Netlify site settings under Site configuration → Build & deploy → Continuous deployment), but set the production branch to something Netlify will never auto-publish. In practice, this means Netlify’s deploy contexts do the heavy lifting for pull requests — they already know how to scope preview deploys to branch URLs — and your GitHub Actions workflow stays responsible for pushing to production only when you say so.
The one catch: if you disabled Netlify’s GitHub integration earlier to prevent double-deploys, re-enabling it means Netlify will also try to build every push. You’ll want to go into Deploy contexts and set “Branch deploys” to “None” while keeping “Deploy previews” on. That way Netlify only wakes up for PRs, not every branch push. Takes about two minutes to configure and you don’t touch a single YAML file.
Option B: Run a Draft Deploy Inside Your Workflow
If you want everything going through GitHub Actions — better audit trail, unified logs, or you’re already running tests in the same pipeline — add a second job that fires on pull_request events and runs a non-production deploy. The key flag is just the absence of --prod:
jobs:
preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Deploy preview to Netlify
id: netlify_preview
run: |
OUTPUT=$(npx netlify-cli deploy \
--dir=dist \
--site=${{ secrets.NETLIFY_SITE_ID }} \
--auth=${{ secrets.NETLIFY_AUTH_TOKEN }} \
--message="PR #${{ github.event.pull_request.number }}")
echo "$OUTPUT"
PREVIEW_URL=$(echo "$OUTPUT" | grep -o 'https://[^ ]*\.netlify\.app' | tail -1)
echo "preview_url=$PREVIEW_URL" >> $GITHUB_OUTPUT
The grep at the end is doing some real work there — Netlify CLI outputs a few URLs (the deploy log URL, the site URL, the deploy-specific URL) and you want the deploy-specific draft URL, which is the last netlify.app link in the output. I found this out the hard way after initially grabbing the wrong URL and linking reviewers to a stale deploy. Run the CLI locally once with --debug if you want to see exactly what the output looks like before trusting a regex.
Posting the Preview URL Back to the PR
A preview URL that lives only in your Actions log is about 40% as useful. Wire it up to a PR comment using actions/github-script so reviewers see it immediately:
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
const previewUrl = '${{ steps.netlify_preview.outputs.preview_url }}';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('Netlify Preview')
);
const body = `### 🚀 Netlify Preview\n\n**URL:** ${previewUrl}\n\n_Updated on push to this PR_`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.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 intentional. If you just createComment every time, you’ll have 15 comments on a PR with any real back-and-forth. Instead this checks whether the bot already left a comment and updates it in place. One comment, always current. You’ll need to make sure your workflow has permissions: pull-requests: write at the job level or it’ll fail silently with a 403 and no useful error message — that one bit me for an embarrassing amount of time.
Gotchas I Hit Along the Way
The build directory thing burned me twice before I internalized it. Gatsby outputs to public/, Vite outputs to dist/, Jekyll outputs to _site/ — and if you pass the wrong path to netlify deploy --dir, the CLI doesn’t throw an error. It just happily uploads an empty directory and Netlify serves a blank site. No warning, no failure exit code, nothing in the logs that screams “you got the path wrong.” I spent 40 minutes convinced I had a caching issue before I realized my --dir=dist was wrong for a Gatsby project. Double-check your framework’s actual output directory before you wire up anything else.
# Gatsby
netlify deploy --dir=public --prod
# Vite
netlify deploy --dir=dist --prod
# Jekyll
netlify deploy --dir=_site --prod
The Site ID vs App ID confusion is genuinely a UI design failure on Netlify’s part. Go to Site Settings > General > Site Information and you’ll see an “App ID” near the top. That is not what you want. Scroll down — or look for “Site ID” specifically — and grab that value. That’s what NETLIFY_SITE_ID in your GitHub secret needs to be. I’ve seen this trip up multiple people on my team who set up their own workflows. The field labeled “App ID” looks authoritative so everyone copies it, wonders why the CLI returns a 404 Not Found, and goes on a 20-minute debugging detour.
If your site uses Netlify Functions, pay attention here: omitting the --functions flag from your deploy command means your functions simply don’t get deployed. No error, no warning — the site deploys fine, but any serverless endpoints return 404s in production. The correct flag is:
netlify deploy --dir=dist --functions=netlify/functions --prod
That path assumes your functions live in netlify/functions/ at the repo root, which is the conventional layout. If you’ve put them somewhere custom, adjust accordingly. I’d also recommend adding a smoke test step after deploy in your Actions workflow that curls one of your function endpoints and fails the pipeline if it gets a 404 back — that catches this immediately instead of after a user reports it.
The rate limiting on the free tier is real and the error messaging is terrible. If you’ve got a team triggering several deploys in quick succession — say, a sprint ending with a bunch of merged PRs — Netlify’s API will start returning 429 Too Many Requests and the GitHub Actions step just fails with something vague like “Request failed” or a non-zero exit code with no human-readable explanation. The fix I use is adding a retry loop with netlify-cli wrapped in a simple bash retry:
- name: Deploy to Netlify
run: |
for i in 1 2 3; do
netlify deploy --dir=dist --prod && break || sleep 15
done
Inelegant but it works for teams doing burst deploys. Upgrading to a paid plan removes most of these limits, but on the free tier during a release afternoon this will bite you at the worst possible moment.
Last one, and this is the misconception that trips up almost every developer new to this setup: environment variables you’ve configured in Netlify’s UI dashboard are only available at Netlify’s build runtime. They are completely invisible to GitHub Actions. Your Actions runner is a separate environment that knows nothing about what you’ve stored in Netlify’s settings. So if your build step needs an API key — say, to fetch content from a headless CMS at build time — you have to add that same variable as a GitHub Actions secret under Settings > Secrets and variables > Actions and explicitly inject it:
- name: Build site
run: npm run build
env:
CONTENTFUL_ACCESS_TOKEN: ${{ secrets.CONTENTFUL_ACCESS_TOKEN }}
PUBLIC_API_URL: ${{ secrets.PUBLIC_API_URL }}
Yes, this means maintaining variables in two places. It’s annoying. The cleanest workaround if you want a single source of truth is to pull variables from Netlify’s API at the start of your workflow using their environment variables endpoint, but that adds complexity most teams don’t want. For most projects, just duplicate the secrets and move on — document it in your repo’s README so the next person setting up the workflow doesn’t spend an hour wondering why process.env.CONTENTFUL_ACCESS_TOKEN is undefined during CI builds.
When to Use This Setup vs Just Letting Netlify Handle It
The honest answer is: Netlify’s native Git integration is genuinely good, and a lot of teams overcomplicate this by reaching for GitHub Actions too early. If you’re the only person touching a project and your main goal is “push code, see it live,” just connect the repo directly in Netlify’s dashboard and call it done. The build starts the second your push lands — no workflow file to maintain, no Actions minutes to burn, no extra abstraction to debug at 11pm when something breaks.
Where native Netlify integration starts showing its limits is the moment you need deploy gates. If you have a test suite — even a small one — you really don’t want it running in parallel with your deploy, you want it running before the deploy even starts. Netlify’s native pipeline can run your build command, but it’s not built to be a CI gate. I switched to the GitHub Actions approach on a client project specifically because a type-check failure was deploying broken TypeScript to production. The build succeeded because the site still compiled — it just compiled with bugs. Actions let me run tsc --noEmit and vitest run as actual blockers before any deploy trigger fires.
The Specific Situations That Should Push You Toward GitHub Actions
- You have multiple environments with different logic — staging might need environment variables scrubbed, production might need a different Netlify site ID, and you want that logic version-controlled in a workflow file, not buried in Netlify’s UI settings.
- Your team uses GitHub’s PR status checks — when reviewers can see “tests: passing, deploy preview: ready” right on the PR without leaving GitHub, review cycles actually move faster. Netlify’s deploy notifications show up as a GitHub Deployment, but your custom Actions checks sit right next to it in the same checks tab.
- You need audit trails — Actions gives you a full log of every step that ran before a production deploy. Netlify’s build logs are good, but they only cover what happened inside the build container, not what triggered it or what checks passed upstream.
The Hybrid Setup Most Teams Actually Land On
I’ve seen this pattern enough times that I’d call it the practical default for teams of 3-10 engineers: let Netlify handle PR previews natively (because the automatic preview URL on every pull request is genuinely one of Netlify’s best features and costs you nothing extra to set up), and route production deploys through GitHub Actions. In practice this means you leave Netlify’s Git integration connected, but you set the production branch to something like release or disable auto-publishing on main via the Netlify API, then trigger production deploys explicitly from your workflow using a deploy hook or the Netlify CLI. PR previews keep working automatically — Netlify still watches the repo — but main only goes live after Actions says so.
# Disable auto-publishing on main via Netlify API
curl -X PUT https://api.netlify.com/api/v1/sites/$SITE_ID \
-H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"auto_publish": false}'
The real tradeoff you need to accept before going the Actions route: you’re adding roughly 1-2 minutes to every production deploy. Netlify’s native pipeline starts a build container the moment a push lands. GitHub Actions has to queue a runner, spin it up, and then your workflow steps run before the deploy even begins. On a busy Actions queue that “1-2 minutes” can stretch. For a marketing site where deploys happen twice a day, that’s nothing. For a team doing 15+ deploys daily, it compounds. I’ve had engineers complain about it, and they’re not wrong — it’s a real cost, not a hypothetical one. Only you can decide if the control is worth it for your specific situation.
Verifying It Works Without Breaking Production
Start Local — Prove the CLI Works Before You Touch GitHub
The single biggest mistake I see is people wiring up the GitHub Actions workflow first and then debugging authentication failures inside a 3-minute CI run. Run the deploy command locally before anything else:
netlify deploy --dir=dist --auth=<your-token> --site=<your-site-id>
Without the --prod flag, this creates a draft deploy — a live URL you can actually click through that won’t touch your production site. Netlify gives you a unique draft URL in the output. If your dist folder looks wrong there, you’ve caught it before it ever touched CI. The thing that caught me off guard the first time was that the --dir path is relative to where you run the command, not the project root. Run it from the wrong directory and you’ll deploy an empty folder with no error — Netlify won’t complain, it’ll just upload nothing.
Add an Auth Check as the First Workflow Step
I always put this before the build step now:
- name: Verify Netlify auth
run: netlify status --auth=${{ secrets.NETLIFY_AUTH_TOKEN }}
If your secret is misconfigured — wrong name, trailing space when you pasted it, expired token — this fails in about 4 seconds instead of after your entire build finishes. The error is also explicit: Not logged in versus some vague deploy failure downstream. Cheap insurance. The netlify status command will also print your linked site name, which doubles as a sanity check that you’re deploying to the right site entirely.
Push to a Feature Branch First — Seriously, Don’t Skip This
Push your workflow file to a feature branch, not main. Your .github/workflows/deploy.yml will still trigger if you’ve scoped it to all branches or explicitly included the feature branch. Watch the Actions tab — confirm every step goes green, confirm the draft deploy URL resolves correctly, and only then merge to main. I’ve seen people push a half-finished workflow directly to main and then spend 20 minutes wondering why production is broken. The branch approach costs you one extra PR and saves you that conversation with your team.
One practical thing: scope your workflow trigger so --prod only fires on main pushes. Feature branches should always use draft deploys. Here’s the conditional pattern I use:
- name: Deploy to Netlify
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
netlify deploy --dir=dist --prod --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} --site=${{ secrets.NETLIFY_SITE_ID }}
else
netlify deploy --dir=dist --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} --site=${{ secrets.NETLIFY_SITE_ID }}
fi
Cross-Reference the Deploy Hash Between GitHub and Netlify
Once you’ve run a production deploy, open Netlify’s dashboard and go to the Deploys tab. You’ll see a deploy ID — a short hash like 63f4a1b. The GitHub Actions log output from the netlify deploy command prints the same hash in its success message. If those match, the deploy that Actions triggered is exactly what went live. If they don’t match — which can happen if someone also has Netlify’s Git integration enabled and both are triggering — you’ve got a double-deploy situation that will cause you confusion down the line. Disable Netlify’s built-in GitHub integration once you’re managing deploys through Actions yourself. Two systems pushing to the same site is a mess.
FAQ
Do I actually need GitHub Actions if Netlify already has built-in CI?
Netlify’s built-in CI is fine until it isn’t. The moment you need to run a custom build step — pulling secrets from AWS Secrets Manager, generating a sitemap from an external API, or running a type-check before deploy — Netlify’s native build pipeline starts feeling cramped. I switched to GitHub Actions for one project specifically because Netlify’s build environment didn’t support a Node version I needed, and the workaround was uglier than just moving the whole pipeline. If your site is a dead-simple Gatsby or Hugo project with no external dependencies, Netlify’s native CI is honestly fine. If you’re doing anything non-trivial, own the pipeline with Actions and use Netlify purely as the deployment target.
What’s the correct way to trigger a Netlify deploy from a GitHub Actions workflow?
You have two options: the Netlify CLI or a direct API call. I prefer the CLI because the error output is much more readable. Here’s the minimal setup that actually works:
- name: Deploy to Netlify
run: npx netlify-cli deploy --prod --dir=./dist
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
Get your NETLIFY_AUTH_TOKEN from User Settings → Applications → Personal access tokens in the Netlify dashboard. The NETLIFY_SITE_ID is under Site Settings → General → Site details — it’s the API ID field, not the site name. Both go into your GitHub repo’s Settings → Secrets and variables → Actions. Drop --prod if you want a draft deploy URL instead, which is genuinely useful for PR previews.
My builds are passing locally but failing in Actions — why?
Nine times out of ten it’s one of three things: Node version mismatch, missing environment variables, or a dependency that got installed globally on your machine but isn’t in package.json. The Node version thing caught me off guard the first time. Add this to your workflow to be explicit:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
The cache: 'npm' line alone can cut your build time by 30-60 seconds on average installs. Also double-check that every environment variable your build script reads is listed in the env: block of your workflow step — Actions won’t throw a helpful error if a variable is undefined, it’ll just silently pass an empty string and your build will fail in a confusing way downstream.
How do I set up preview deploys for pull requests without paying for Netlify’s higher tiers?
Netlify’s free tier includes deploy previews natively if you connect the repo through their dashboard — that’s the path of least resistance. But if you’re managing the pipeline through GitHub Actions (which is the whole point here), remove the repo connection from Netlify’s CI and instead add a separate workflow job that fires on pull_request events and deploys without the --prod flag:
on:
pull_request:
branches: [main]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Deploy Preview
run: npx netlify-cli deploy --dir=./dist --message="PR #${{ github.event.pull_request.number }}"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
The CLI outputs a draft URL in the logs. You can then use the actions/github-script action to post that URL as a PR comment automatically — takes about 10 extra lines and makes the workflow actually useful for teammates doing review.
Will running deploys through GitHub Actions use up my Netlify build minutes?
No, and this is the main hidden advantage of this setup. When you push via the CLI with an already-built dist folder, Netlify just receives the files — it doesn’t run a build on its end. You’re consuming GitHub Actions minutes (2,000 free per month on the Free plan for public repos, unlimited) rather than Netlify’s build minutes (300 per month on the free tier). For teams that deploy frequently, this alone is worth the setup time. Netlify charges $7/month per 500 additional build minutes if you burn through the free tier, so offloading the build to Actions is a real cost move, not just an architectural preference.
Should I disable Netlify’s automatic deploys after setting this up?
Yes, immediately. If you leave both active, every push to your connected branch triggers two deploys — one from Netlify’s native CI, one from your Actions workflow. You’ll see double the build minutes consumed, race conditions on which deploy “wins” as the live site, and confusing deploy logs. Go to Site Settings → Build & deploy → Continuous deployment and stop auto-publishing, or disconnect the GitHub integration entirely if you want Actions to be the sole trigger. I’ve seen people miss this step and spend an hour debugging why their environment variables aren’t applying — one deploy was using the old pipeline, the other the new one.