Why You’d Want GitHub Actions Handling Your Netlify Deploys
Netlify’s built-in Git integration is genuinely good — until you need it to do something slightly outside its happy path. The second your build process requires more than “install, build, deploy,” you hit a wall. You can’t inject a secret mid-build without exposing it as an environment variable upfront. You can’t say “run these Playwright tests and only deploy if they pass.” You can’t hit an external API to pull CMS content, process the output, and feed it into your build — not reliably, anyway. I switched my last three projects off Netlify’s native CI specifically because I needed that sequencing, and the platform just wasn’t designed to give you that kind of control.
Here’s the specific scenario that broke the camel’s back for me: a Gatsby site pulling content from Contentful at build time, with Playwright smoke tests running against a preview deployment before production gets touched. Netlify’s build pipeline doesn’t give you conditional stages. Everything runs in one big blob of shell, and if your test runner exits non-zero, you’ve already uploaded your deploy artifacts by that point. The deploy had either fired or was queued — there was no clean gate. With GitHub Actions, that whole flow becomes three explicit jobs with needs dependencies and if conditions. The CMS fetch runs first, the build uses its output, tests run against the preview URL, and production only gets the deploy trigger if everything passes. That’s not clever engineering — that’s just having a real CI system.
What you actually get from this setup is a single CI system for your entire organization. Instead of half your repos using GitHub Actions and one special snowflake using Netlify’s build queue, everything lives in .github/workflows/. Your team already knows how to read those YAML files. Secrets management is unified through GitHub’s encrypted secrets store. You can reuse composite actions across repos. And critically, you get environment protection rules — meaning you can require a manual approval before a production deploy fires, which Netlify’s free tier doesn’t offer natively.
Getting Selenium to Actually Work in CI/CD for JavaScript Apps (Without Losing Your Mind)
The tradeoff is real though: you’re giving up Netlify’s build minutes and using GitHub Actions’ allocation instead. On public repos, GitHub gives you 2,000 free minutes per month on Linux runners (as of when I last checked their pricing page — confirm current limits at GitHub’s billing docs and Netlify’s pricing page before you commit). For private repos, minutes cost money and the multiplier kicks in depending on runner type. If your builds are fast and infrequent, the math probably favors this setup. If you’re running 20-minute builds on every PR across a dozen contributors, run the numbers first.
One thing that caught me off guard: when you take Netlify’s CI out of the picture, you need to explicitly handle deploy previews yourself. Netlify auto-generates preview URLs for every branch when its own CI is active. Once you’re deploying via the API from Actions, you own that logic — you call the Netlify API with the --alias flag or set the deploy context manually. It’s not hard, but it’s not automatic either. For teams that rely heavily on Netlify’s preview URLs in their review process, that’s worth factoring in before you rip out the native integration. For a broader look at CI/CD tooling and where this fits in a full automation stack, check out our guide on Productivity Workflows.
Postman vs Insomnia in 2026: I Ran Both in a 12-Service Mesh and Here’s What Broke
What You Need Before Writing a Single Line of YAML
The thing that catches most people off guard is Netlify’s auto-deploy feature. If you link your GitHub repo through Netlify’s UI and also set up a GitHub Actions workflow to push builds, every single push will trigger two deploys. You’ll burn through your build minutes, get inconsistent deploy logs split across two systems, and spend an afternoon debugging why your site sometimes shows stale content. Go to Site Settings → Build & Deploy → Continuous Deployment and unlink the repo entirely. Let GitHub Actions own the deploy process — Netlify is just the destination.
Before anything else, you need two values that will live in GitHub Secrets and nowhere else. First, your Site ID: find it under Site Settings → General → Site information. It looks like a UUID — something like 3a2b1c4d-5e6f-7890-abcd-ef1234567890. Second, a personal access token: go to your Netlify avatar → User Settings → Applications → Personal access tokens, click “New access token”, give it a name like github-actions-deploy, and copy it immediately — Netlify won’t show it again. Add both to your GitHub repo under Settings → Secrets and variables → Actions:
5 Low-Code Platforms Financial Analysts in Fintech Startups Actually Use (And Which Ones Hit a Wall Fast)
NETLIFY_AUTH_TOKEN → your personal access token
NETLIFY_SITE_ID → your site UUID
I’ve seen people hardcode the Site ID directly in the YAML because “it’s not really a secret.” Don’t. Keeping both values in Secrets means you can rotate credentials, reuse the same workflow template across repos, or hand the repo to a contractor without exposing your Netlify account structure. The auth token is obviously sensitive, but the Site ID also tells an attacker exactly which deploy endpoint to target. Keep them both out of version control, full stop.
Install the Netlify CLI locally — you’ll use it to test deployments before you trust the Actions workflow with it:
npm install -g netlify-cli
netlify login
The login command opens a browser OAuth flow and writes credentials to ~/.netlify/config.json. Once you’re authenticated, you can do a manual deploy from your build output folder with netlify deploy --dir=dist for a draft URL, or netlify deploy --dir=dist --prod to push live. I always do this at least once before wiring up Actions — it confirms your Site ID is correct, your token works, and your dist (or public, or out, whatever your framework spits out) directory is actually what Netlify expects. Skipping this step and debugging a broken deploy through Actions logs is miserable.
One more thing worth getting right upfront: make sure the site you created in Netlify was set up through the UI as a blank site, not by importing from Git. If you originally connected it via the Git import flow, Netlify may have stored build settings (build command, publish directory) that will conflict with what your Actions workflow does. Check Site Settings → Build & Deploy → Build settings and clear the build command field if it’s populated — when GitHub Actions is handling the build, Netlify doesn’t need to know how to build your project, only where to find the output you’re sending it.
The Baseline Workflow File — Production Deploy on Main
Drop the file here and GitHub does the rest
Create .github/workflows/deploy.yml in your repo root and GitHub Actions picks it up with zero additional configuration. No dashboard toggle, no registration step — the file existing is enough. That simplicity is genuinely one of the better design decisions in the ecosystem. Here’s the full workflow I use for Node-based static sites:
name: Deploy to Netlify
on:
push:
branches:
- main # only fires on pushes to main, not every branch
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# Check out the full repo (not a shallow clone)
- name: Checkout
uses: actions/checkout@v4
# Pin Node version explicitly — don't let the runner surprise you
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # caches node_modules between runs, saves ~30s
# Clean install — ci is stricter than install, respects package-lock
- name: Install dependencies
run: npm ci
# Your framework's build command goes here
- name: Build
run: npm run build
# The deploy step — this is the one that actually matters
- name: Deploy to Netlify
run: |
npx netlify-cli deploy \
--prod \
--dir=dist \
--auth=$NETLIFY_AUTH_TOKEN \
--site=$NETLIFY_SITE_ID \
--message="Deploy ${{ github.sha }}"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
The --prod flag is non-negotiable if you want a live deploy. Without it, Netlify treats the upload as a draft deploy — it gets a unique preview URL but your main site URL doesn’t update. I’ve seen people omit it and spend twenty minutes wondering why the site isn’t changing. The Netlify CLI gives you no warning about this distinction, which is exactly the kind of silent footgun that wastes real time.
The --dir=dist gotcha will burn you at least once. If you point it at the wrong folder, Netlify deploys successfully — you get a green checkmark in the Actions log, a deploy ID in the dashboard, everything looks fine — but your site is either blank or serving stale files. There’s no error because Netlify doesn’t know what folder you intended. It just deploys whatever you gave it. Here’s the mapping I keep in my notes:
- Vite (React, Vue, Svelte with Vite) →
dist - Next.js static export (
output: 'export'in config) →out - Gatsby →
public - Astro →
dist(but verify — some integrations change this) - Create React App →
build
Run npm run build locally and look at what folder appears. Don’t assume. I switched a project from CRA to Vite last year and forgot to update --dir — deployed a blank site to production during a demo. Not ideal.
The --message flag is small but valuable. Passing the git SHA ties every Netlify deploy entry back to a specific commit. In the Netlify dashboard under Deploys, you’ll see the message alongside timestamps and deploy duration. When something breaks at 2am and you’re digging through deploy history trying to figure out which push caused it, having the SHA right there beats comparing timestamps across two dashboards. The syntax inside the YAML is ${{ github.sha }} — GitHub Actions expands that to the full 40-character commit hash before the shell sees it, so the --message value is just a plain string by the time Netlify receives it.
One thing I’d add: install netlify-cli as a dev dependency in your project rather than relying solely on npx. Add it with npm install --save-dev netlify-cli and change the deploy command to ./node_modules/.bin/netlify deploy ... or just netlify deploy ... after npm ci restores it. The reason is version consistency — npx netlify-cli without a pinned version will pull whatever is latest on the day the job runs, and Netlify has shipped breaking CLI changes in minor versions before. Pinning it in package.json means a CLI update only happens when you intentionally bump it, not silently on the next push.
Deploy Previews for Pull Requests — the Feature Worth the Setup
Why Deploy Previews Change How Your Team Reviews Frontend Work
The first time a non-technical stakeholder clicked a live preview link in a PR comment and left feedback without anyone screenshotting anything, I understood why this feature alone justifies the whole GitHub Actions setup. No more “can you share your local?” Slack messages. No more merging something you couldn’t actually see running. The setup takes maybe 30 minutes and it changes the review dynamic completely.
Triggering Only on Pull Requests
Keep your deploy preview logic in a separate job — don’t bolt it onto your production job with a conditional. Separate jobs are easier to read, easier to disable, and the failure blast radius is smaller. Your workflow file should have two distinct jobs: deploy_production triggered on push to main, and deploy_preview triggered on pull_request. Here’s the trigger block:
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
The synchronize type is the one people forget — it fires when new commits are pushed to an existing PR branch. Without it, you only get a preview on the first PR open and then it goes stale. reopened is less critical but costs nothing to include.
Running the Draft Deploy and Capturing the URL
Drop the --prod flag and Netlify hands you a unique draft URL instead of touching your live site. The tricky part is actually capturing that URL from stdout and passing it to later steps. Here’s how I do it, using the --json flag plus jq — and I’ll explain why that matters in a second:
jobs:
deploy_preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Deploy draft to Netlify
id: netlify_deploy
run: |
OUTPUT=$(npx netlify-cli deploy \
--dir=dist \
--json \
--auth=${{ secrets.NETLIFY_AUTH_TOKEN }} \
--site=${{ secrets.NETLIFY_SITE_ID }})
DRAFT_URL=$(echo "$OUTPUT" | jq -r '.deploy_url')
echo "draft_url=$DRAFT_URL" >> $GITHUB_OUTPUT
You reference this later as ${{ steps.netlify_deploy.outputs.draft_url }}. Clean, reliable, no magic.
The Gotcha That Bit Me: CLI Version and Stdout Parsing
Here’s the thing that cost me an afternoon: if you try to parse the URL without --json using older versions of the CLI, you’re scraping human-readable terminal output that changes format between releases. I tried pattern-matching with grep and it broke silently after a Netlify CLI update. The --json flag has been stable since netlify-cli v17 and gives you a proper JSON object with a deploy_url key you can trust. Always pin your CLI version in CI or at least check the range. I now do npx netlify-cli@latest in local testing but pin a minor version in the workflow file with something like [email protected] so I don’t get surprised on Monday morning.
Posting the Preview URL as a PR Comment
This is the part that makes the whole thing visible to everyone. Use actions/github-script to post a comment directly on the PR. Paste this as a step after your deploy:
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
const draftUrl = '${{ steps.netlify_deploy.outputs.draft_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(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('')
);
const body = `
### 🚀 Netlify Preview Deployed
**Draft URL:** ${draftUrl}
_Updated on every 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 HTML comment <!-- netlify-preview --> is a trick I picked up after the PR comment thread turned into a wall of bot spam — one update per push, not one new comment per push. The script checks if a bot comment with that marker already exists and updates it instead of creating a new one. Small detail, huge quality-of-life improvement when a PR has 20 commits on it.
One last thing: make sure your workflow has permissions: pull-requests: write set at the job level, otherwise github-script will fail with a 403 and the error message won’t immediately tell you why. This catches almost everyone the first time.
Gating the Deploy on Tests — the Actual Reason to Do This
The whole point of routing deployments through GitHub Actions instead of letting Netlify auto-deploy from your repo is this: you get a real test gate. If your tests fail, zero bytes go to Netlify. That’s the deal. And it’s genuinely useful once you’ve shipped a broken homepage to production because a CSS import got renamed and nobody caught it before merge.
The mechanism is needs: [test] on your deploy job. GitHub Actions won’t start the deploy job until every job listed in needs completes successfully. Here’s a complete, working workflow that runs Vitest unit tests and a Playwright smoke test against the actual built output — not a dev server — before touching Netlify:
name: Test and Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Run Vitest unit tests
run: npm run test
- name: Build site
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Playwright smoke test against built output
run: npx playwright test --config=playwright.smoke.config.ts
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- 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 }}
A few things worth calling out here. First, notice the build happens inside the test job, not the deploy job. The dist/ folder gets passed between jobs via actions/upload-artifact and actions/download-artifact. This means the deploy job is shipping the exact same build that Playwright tested — not a fresh rebuild that could theoretically differ. That’s not paranoia, that’s correctness.
The caching setup above uses hashFiles('**/package-lock.json') as the cache key. Every time your lockfile changes, the cache busts automatically and a fresh npm ci runs. When the lockfile hasn’t changed — which is most commits — you skip the full install entirely. I’ve measured this saving anywhere from 45 to 90 seconds depending on how heavy the dependency tree is. The restore-keys fallback means if there’s no exact match, Actions will still try to restore the closest prefix match rather than starting cold. Don’t skip the fallback key; it matters on first runs after a dependency bump.
The Playwright smoke test deserves its own config file (hence playwright.smoke.config.ts above). Keep it lean — I’m talking three or four tests maximum: does the homepage load, does the nav render, does the primary CTA exist in the DOM. You’re not doing full E2E here. The point is catching “something is catastrophically broken” before it goes live, not replacing your full test suite. Point it at a local server spun from the built output using @playwright/test‘s webServer config:
// playwright.smoke.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/smoke',
webServer: {
command: 'npx serve dist -p 3001',
port: 3001,
reuseExistingServer: false,
},
use: {
baseURL: 'http://localhost:3001',
},
});
Now for the honest take: Netlify’s built-in CI just can’t do this cleanly. If you want to block a deploy on an external test suite using Netlify’s native pipeline, you’re looking at disabling auto-publishing, triggering builds via the Netlify API, polling the deploy status endpoint, and then conditionally calling the publish endpoint if your tests pass — all scripted yourself. I’ve seen people do it, and it works, but it’s fragile webhook plumbing that you now have to maintain. GitHub Actions gives you this gate with a single field on the job definition. That asymmetry is real, and it’s the primary reason I’d push a team toward this setup over relying on Netlify’s built-in deploy hooks for anything beyond trivial projects.
Injecting Build-Time Environment Variables Safely
The thing that caught me off guard the first time I set this up: I had diligently added all my environment variables to Netlify’s dashboard, triggered a deploy via GitHub Actions, and then watched my build fail with undefined API keys. Spent 45 minutes debugging before I realized — Netlify’s environment variable UI only injects variables during Netlify’s own build runner. Since I’d disabled that and handed builds over to GitHub Actions, those variables were completely invisible to my build process. Dead on arrival.
The correct pattern is simple once you understand where the build actually happens. Store your secrets in GitHub — go to your repo’s Settings → Secrets and variables → Actions and add them there. Then pass them explicitly in your workflow’s build step using the env: key. Here’s what that looks like for a Vite project with a public API key:
- name: Build site
run: npm run build
env:
VITE_API_KEY: ${{ secrets.VITE_API_KEY }}
VITE_PUBLIC_BASE_URL: ${{ secrets.VITE_PUBLIC_BASE_URL }}
Vite (and Create React App with its REACT_APP_ prefix, same idea) will pick up any environment variable with the right prefix at build time and bake it directly into the JavaScript bundle. That means the value is static — compiled into your dist/ folder before Netlify ever sees a file. This is why the naming convention matters: Vite specifically only exposes variables prefixed with VITE_ to client-side code. Anything without that prefix stays server-side only. If you’re using a framework like Astro or Next.js in static export mode, check their specific prefix rules — they all have one.
The Build-Time vs Runtime Split You Need to Understand
Before you go injecting everything this way, there’s a sharp distinction worth getting clear on. Build-time variables get compiled into your static bundle — they live in your HTML/JS files and are readable by anyone who opens DevTools. Use these for public-facing keys that must be exposed to the browser anyway (like a read-only Maps API key or a Stripe publishable key). Runtime variables are for Netlify Edge Functions or Netlify Functions — those run server-side on Netlify’s infrastructure, and those you should configure in Netlify’s environment variable UI. That context still applies for serverless functions even when your main build runs in GitHub Actions. Mixing these up is how you accidentally expose a secret API key to the entire internet — it’ll sit right there in your bundle, visible in the source.
Keeping the Secret Actually Secret During CI
GitHub masks secret values in logs automatically — if a secret accidentally gets echoed, you’ll see *** instead. That’s solid. But I’d still avoid patterns like run: echo $VITE_API_KEY in your workflow for debugging, even temporarily. The masking works for exact matches, but partial strings or encoded variants can sometimes slip through. The safer debug move is to verify the variable is defined without printing its value:
- name: Check secrets are loaded
run: |
if [ -z "$VITE_API_KEY" ]; then
echo "VITE_API_KEY is NOT set — check GitHub Secrets"
exit 1
else
echo "VITE_API_KEY is set"
fi
env:
VITE_API_KEY: ${{ secrets.VITE_API_KEY }}
One more gotcha: GitHub Secrets are not available to pull requests from forked repositories by default — which is the right security call, but it means your CI build will fail for external contributors if it needs those secrets to build. If you’re open source and need forks to build successfully, you either need a public fallback value for non-sensitive build vars, or you restructure the build to be runnable without that key. Private repos don’t have this problem since you control who forks. Just something to decide intentionally rather than discover during a code review.
Gotchas I Hit That the Docs Don’t Mention
The Silent Deploy Problem Burned Me First
The thing that caught me off guard immediately was how quiet netlify deploy --prod is when it works. The CLI exits 0, the step turns green, but your log just shows… nothing. The first few times I ran this pipeline I sat there staring at the GitHub Actions log wondering if the job had frozen mid-deploy or if the runner was waiting on something. It hadn’t. It was done. The fix is embarrassingly simple — just add an echo after the command:
- name: Deploy to Netlify
run: |
netlify deploy --prod --dir=dist --auth=$NETLIFY_AUTH_TOKEN --site=$NETLIFY_SITE_ID
echo "Deploy succeeded"
Now at least you have a visible signal in the log. I know it feels like a hack, but the CLI genuinely doesn’t print a success message by default and I’d rather have the redundant echo than keep second-guessing the step.
The Trailing Newline That Broke My Site ID For Two Hours
If your workflow throws Error: No such site, your first instinct is to double-check that the site ID is right. You’ll go to Netlify, copy the site ID again, and it’ll still fail. Here’s why: if you used pbcopy to grab the value from your terminal — something like netlify sites:list | grep your-site | pbcopy — you almost certainly copied a trailing newline along with the actual ID. GitHub Secrets doesn’t strip that whitespace. The API call then sends abc123def\n instead of abc123def and Netlify legitimately can’t find a site with that name.
The fix: open your Netlify dashboard, navigate to Site settings → General → Site details, and manually type the Site ID into the GitHub Secret field. Don’t paste from terminal output. I know that sounds like unnecessary paranoia but I’ve now seen this trip up three separate people I’ve paired with.
Ubuntu Runners Are the Default and They Will Silently Break Image Tools
GitHub Actions defaults every job to runs-on: ubuntu-latest and for most Node/static site builds that’s totally fine. Where it bites you is if your build pipeline uses something like sharp, vips, or any image processing binary that ships prebuilt macOS dylibs. The build will either fail outright or — worse — silently produce wrong output. If you hit this, swap to runs-on: macos-latest:
jobs:
deploy:
runs-on: macos-latest
The honest trade-off: macOS runners burn through your Actions minutes roughly 10x faster than Linux runners under GitHub’s billing model. If you’re on the free tier, that matters. Most static site builds don’t need macOS — only switch if you’ve confirmed the binary issue. For everything Gatsby, Astro, Next.js static export, Hugo — Ubuntu is fine.
Rate Limits Are Real on the Free Plan — Use Concurrency Keys
Netlify’s free plan caps API-based deploys and if you’re pushing to a branch that gets a lot of commits — a shared main branch on a team, or a branch that CI systems also push to — you will eventually hit 429 responses from the Netlify API. The jobs will fail mid-deploy with an auth or rate limit error that’s easy to misread as a credentials problem.
The real fix is stopping the pile-up before it starts. Add a concurrency key to your workflow so newer runs cancel in-flight ones:
concurrency:
group: netlify-deploy-${{ github.ref }}
cancel-in-progress: true
This means if three commits land in quick succession, only the most recent deploy attempt runs to completion. The previous ones get cancelled. Your site still ends up on the latest code, you stop hammering the API, and your Actions queue doesn’t pile up. I switched to this pattern after hitting my first 429 and haven’t seen one since.
Pin the CLI Version or Accept That Your Pipeline Will Break On a Random Tuesday
A lot of tutorials tell you to install the Netlify CLI globally in your workflow step like this:
- run: npm install -g netlify-cli
Don’t. That installs whatever the latest version is on the day the runner executes, which means a breaking CLI release can silently nuke your pipeline with zero changes on your end. I had a deploy workflow that ran perfectly for two months and then started failing because a major CLI version changed the behavior of the --dir flag. I spent 40 minutes thinking my build output path had changed before I realized the CLI had updated.
Instead, add it to your devDependencies and pin it:
"devDependencies": {
"netlify-cli": "17.x.x"
}
Then in your workflow just run npx netlify deploy ... or call it directly from ./node_modules/.bin/netlify. Now your CLI version is committed in your repo, Dependabot can manage intentional upgrades, and a random CLI publish can’t break production deploys without you knowing about it first.
Complete Working Workflow — Copy-Paste Starting Point
The Full Workflow, Annotated
Here’s the directory structure first, because I’ve seen people create the file in the wrong place and wonder why nothing triggers. Your workflow file lives at exactly this path from your project root:
my-project/
├── .github/
│ └── workflows/
│ └── deploy.yml ← this exact file
├── src/
├── public/
├── package.json
└── netlify.toml ← optional but recommended
The .github/workflows/ directory must be at the repo root. Not inside src/, not inside a subfolder. GitHub looks for it specifically there. Now, here’s the full production-ready YAML. Read the comments — the non-obvious decisions are all explained inline:
name: Deploy to Netlify
on:
push:
branches:
- main # production deploys only on main
pull_request:
branches:
- main # PR previews for anything targeting main
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# Cache node_modules based on package-lock.json hash.
# If lock file hasn't changed, this step restores the full
# cache and npm ci finishes in ~5s instead of ~45s.
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20' # pin this — don't use 'latest' or builds break randomly
- name: Install dependencies
run: npm ci # ci is stricter than install; fails on lock file mismatch
# Gate. If tests fail, the deploy steps below never run.
# Remove this block if you genuinely have no tests yet,
# but add it back the moment you write your first test.
- name: Run tests
run: npm test
- name: Build site
run: npm run build
env:
# Expose any env vars your build needs here.
# Don't hardcode values — pull from GitHub secrets.
NODE_ENV: production
PUBLIC_API_URL: ${{ secrets.PUBLIC_API_URL }}
# PR PREVIEW DEPLOY
# This step only runs on pull_request events.
# It creates a unique deploy preview URL per PR — not production.
- name: Deploy PR Preview
if: github.event_name == 'pull_request'
uses: nwtgck/[email protected]
with:
publish-dir: './dist' # ← CHANGE THIS per framework (see below)
github-token: ${{ secrets.GITHUB_TOKEN }} # needed to post preview URL as PR comment
deploy-message: "PR Preview — ${{ github.event.pull_request.title }}"
enable-pull-request-comment: true # posts the preview URL as a comment on the PR
enable-commit-comment: false # personal preference; avoids comment spam on commits
netlify-config-path: ./netlify.toml # only needed if you have redirect rules etc.
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
# PRODUCTION DEPLOY
# Only runs when pushing/merging to main.
# The --prod flag is critical — without it, Netlify treats every
# deploy as a branch deploy, not your live production URL.
- name: Deploy to Production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: nwtgck/[email protected]
with:
publish-dir: './dist' # ← same as above, match your framework
production-deploy: true # sets the --prod flag; this is your live site
deploy-message: "Production — ${{ github.sha }}"
enable-pull-request-comment: false
enable-commit-comment: false
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 was the production-deploy: true flag. I deployed without it, everything looked fine in the Netlify dashboard, but my production URL was stuck on the old version. Netlify was happily creating branch deploys every time and never promoting them. Cost me 20 minutes of confused staring. Set that flag explicitly and you’ll never hit that.
The publish-dir is the other thing you’ll need to change per framework. Here’s a quick reference:
- Astro:
./dist— default output, don’t touch anything unless you’ve overriddenoutDirinastro.config.mjs - Next.js (static export):
./out— you must haveoutput: 'export'innext.config.js, otherwise Next.js doesn’t produce static files at all and your deploy will be empty - Nuxt (static generation):
./distfor Nuxt 2,./.output/publicfor Nuxt 3 — this trips up a lot of people migrating between versions - Plain HTML / vanilla projects:
./publicif that’s where your files are, or.(the repo root) if yourindex.htmlsits at root — just be careful you’re not accidentally deploying yournode_modulesfolder by doing that - Vite (generic):
./dist— Vite’s default, same as Astro since Astro uses Vite under the hood
One more practical note: the two secrets you need — NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID — live in your GitHub repo under Settings → Secrets and variables → Actions. The auth token comes from Netlify’s user settings at app.netlify.com/user/applications, and the site ID is on your Netlify site’s general settings page labeled “Site ID”. Neither of these rotates automatically, so if you ever revoke the token in Netlify and forget to update GitHub, your deploys will silently fail with a 401 and you’ll get a vague “Error: Command failed” message that doesn’t make it obvious what happened. I’d add a calendar reminder to rotate those tokens every six months.
When to Just Use Netlify’s Native Git Integration Instead
The Native Integration Is Often the Right Call
I’ll be blunt: the GitHub Actions + Netlify setup I’ve been walking through in this guide is genuinely overkill for a lot of projects. I switched one of my personal sites back to native Netlify Git integration after running it through Actions for three months, because I kept asking myself what I was actually getting from the extra complexity. The answer was nothing. If your deploy pipeline is literally npm run build and pushing the dist/ folder to Netlify — no E2E tests, no external API calls, no multi-stage process — the native integration does that in about four clicks and zero YAML.
Here’s the thing that catches junior devs off guard with the Actions approach: debugging broken workflows is a non-trivial time sink. You push a change, wait 2-3 minutes for the runner to spin up, watch it fail at a weird step, read logs that are sometimes genuinely cryptic, and repeat. Native Netlify deploys give you clean, readable build logs right in the dashboard and most errors surface immediately. If your team isn’t deep in Actions yet, the cognitive overhead of learning on.push triggers, job contexts, and secret scoping just to deploy a marketing site isn’t worth it. Save that learning curve for a project that actually needs it.
The Build Plugin Problem Is Real
This one bit me hard. Netlify’s build plugin ecosystem — specifically the Essential Next.js plugin (@netlify/plugin-nextjs) — hooks into the Netlify build process itself. When you pre-build externally with GitHub Actions and push a finished artifact via netlify deploy --dir=.next, those plugins never run. You’re deploying a static artifact, not triggering a Netlify build, so the plugin lifecycle never fires. I spent an afternoon wondering why my Next.js Image Optimization wasn’t behaving correctly on Netlify before realizing the plugin wasn’t executing at all. Check the Netlify Build Plugins docs before committing to the Actions path — if you rely on any of those plugins, the native integration is your only real option.
- @netlify/plugin-nextjs — requires a Netlify-initiated build to configure edge functions and image handling correctly
- netlify-plugin-inline-critical-css — runs as a post-processing step inside Netlify’s build environment, not available for external deploys
- netlify-plugin-a11y — same issue; it hooks into the build output after Netlify’s own build runs
The irony of the free plan situation is something I find myself explaining regularly. Netlify’s free tier gives you 300 build minutes per month. GitHub Actions gives you 2,000 minutes per month on the free tier for public repos, and a meaningful chunk for private ones. If you push builds through Actions, Netlify sees a pre-built artifact and charges you zero Netlify build minutes — you’ve shifted the compute to GitHub. So if you’re on Netlify free and doing frequent deploys, Actions can actually stretch your limits. But flip that around: if you’re on GitHub Free and your project is simple, you’re burning GitHub minutes for no reason when Netlify’s native CI would use Netlify’s build pool instead, which doesn’t touch your Actions quota at all.
A Quick Decision Checklist
Before you wire up Actions, run through this honestly:
- Do you have test gates (unit, integration, E2E) that must pass before a deploy kicks off?
- Do you need to pull secrets from somewhere other than Netlify’s environment variable UI (e.g., AWS Secrets Manager, Vault)?
- Are you building a monorepo where only certain path changes should trigger a site deploy?
- Do you need deploy previews gated on a separate approval step?
- Are you deploying to multiple targets (staging on Netlify, production on S3) from the same pipeline?
If you answered no to all five, close the Actions tab. Connect the repo directly in Netlify’s UI under Site configuration → Build & deploy → Continuous deployment, set your build command and publish directory, and you’re done in under five minutes. The native integration handles branch deploys, deploy previews, and rollbacks without any config files. That’s not a knock on GitHub Actions — it’s just matching the tool to the actual problem.