A GitHub Actions Deploy For Any Static Framework Build

Set up a GitHub Actions deploy that builds Vite, Astro, Next.js export or Docusaurus in your own runner and ships only the output folder. Copyable workflow.
The OxyPages Team · · 8 min read
Your project builds on your laptop. You run npm run build, a folder appears, and the site works when you open it. Now you want that to happen on every push, without you sitting there.
There are two ways to get it. The host can pull your repository and build it on its own machines. Or you can build it yourself, in GitHub's machines, and hand the host only the finished folder.
This post is about the second way: a GitHub Actions deploy. It's the one I reach for when I want the build to stay mine, because the same workflow works with almost any static host and almost any framework that can write plain files.
The idea fits in one sentence. GitHub runs your build on a fresh machine, then a final step uploads the output folder, and nothing else, to wherever the site lives.
Below is a workflow you can copy, the output folder each common framework writes, where your secrets go, a worked example on a docs site, and an honest look at when letting the host build is the better call. If you're starting from a Vite project, how to deploy a Vite app from its dist folder covers the folder side first.
TL;DR: A GitHub Actions deploy checks out your code, installs dependencies with npm ci, runs your build, and uploads only the output folder (dist, build, out or _site) to your host. It works for Vite, Astro, Next.js with a static export, Docusaurus and SvelteKit's static adapter, because all of them end in plain files. Keep API keys in repository secrets, never in the repo. Use actions/setup-node with cache: npm so installs stay quick. GitHub's billing docs, checked November 2026, say standard runners are free for public repositories; private ones draw on a monthly included allowance. If any page renders on a server at request time, a static upload can't serve it.
What Does A GitHub Actions Deploy Actually Do?
A GitHub Actions deploy is a workflow file in your repository that builds the site on a GitHub-hosted machine and then sends the result to your host. GitHub calls that machine a runner. It starts empty every time, so the workflow has to set it up from scratch.
Every version of this workflow, for every framework, does the same five things:
- Check out the code. The runner copies your repository at the commit you pushed.
- Install Node. You pick the version, so the build matches your laptop.
- Install dependencies.
npm ciinstalls exactly what your lock file says, nothing newer. - Run the build. Usually
npm run build, which writes the output folder. - Upload the output folder. One step, specific to your host, sends only that folder.
Step five is the only part that changes between hosts. Everything above it is portable, which is why I like this setup. If you move hosts next year, you swap one step.
The reason you upload only the output is simple. A browser can't run your src folder. It can only load the HTML, CSS and JavaScript your build produced. Sending node_modules along would just be thousands of files nobody visits.
The Output Folder Each Framework Produces
The output folder is the one directory your build writes the finished site into, and it's the only thing your workflow should upload. Get its name wrong and the deploy either fails or publishes your source code.
| Framework | Build command | Output folder | Watch out for |
|---|---|---|---|
| Vite (React, Vue, Svelte, Solid) | npm run build | dist | A single-page app needs its host to send unknown paths to index.html |
| Astro (default static output) | npm run build | dist | Server output needs an adapter and a host that runs code |
| Next.js | npm run build with output: 'export' set | out | Without that setting there is no out folder at all |
| Docusaurus | npm run build | build | Set baseUrl if the site lives in a subfolder |
| SvelteKit with adapter-static | npm run build | build | A route that can't be prerendered fails the build unless you set a fallback page |
| Eleventy | npx @11ty/eleventy | _site | None worth naming |
| Gatsby | npm run build | public | public is also a source folder in some other tools |
That table is the whole compatibility story. If your framework can write a folder of plain files, a static upload can serve it.
Next.js is the one that catches people. Its default is to render on a server, and a static export is a setting you switch on, with a list of features you give up. Treat that as a separate decision, not a deploy detail.
A Generic Workflow You Can Copy
Here is a workflow that builds any of the frameworks above. Save it as .github/workflows/deploy.yml in your repository. The last step is a placeholder, because that's the part your host supplies.
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
# Replace this step with your host's upload step.
# It should send ONLY the output folder, e.g. ./dist
- run: echo "upload ./dist here"
Read it top to bottom and it's the five steps from earlier, in YAML. The on: push block means only pushes to main trigger it. Push to a feature branch and nothing deploys.
Where Your Secrets Go
A deploy step needs some kind of key, and that key goes in a repository secret, never in the workflow file. In GitHub, open your repository's Settings, then Secrets and variables, then Actions, and add it there. The workflow reads it like this:
- run: ./upload.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
GitHub masks secret values in the logs, so a key won't print by accident.
One warning matters more than the rest. A build-time variable whose name starts with VITE_, NEXT_PUBLIC_ or PUBLIC_ gets copied into the JavaScript your visitors download. Feed a secret into one of those and it's public, workflow secrets or not. why your Vite environment variables are public explains exactly which values leak.
Caching npm So Builds Stay Quick
The cache: npm line in setup-node saves npm's download cache between runs and restores it next time, keyed on your lock file. On a project with a few hundred packages, that's the difference between downloading everything on every push and downloading almost nothing.
It also explains why the workflow uses npm ci instead of npm install. ci refuses to run if package.json and the lock file disagree, and it never rewrites the lock file, so the build on GitHub matches the build you tested. npm's documentation for the ci command lists the exact differences.
Worked Example: A Docusaurus Site On Every Push

Say you keep product docs in Docusaurus, in a private repository, and you want every merge to main to go live. Docusaurus writes its output to build, so that's the folder to ship.
I'll use the OxyPages action here, because it's the one I know line by line. Any host with an upload step looks much the same.
- Create an API key in your account under Profile, then API Keys. Every plan can use one, including Free, once your email is verified.
- Add it as a repository secret, using the secret name the snippet below reads.
- Add your site id as a repository variable, not a secret. It isn't sensitive.
- Replace the placeholder step with this:
- uses: oxypages/deploy-action@v1
with:
api-key: ${{ secrets.OXYPAGES_API_KEY }}
site-id: ${{ vars.OXYPAGES_SITE_ID }}
build-dir: ./build
You can drop the separate npm ci and build steps and pass build-command: npm run build to the action instead, since it installs dependencies first when it sees a package.json. I keep them separate so a failed build shows up as a failed build, not a failed deploy.
What happens on each push:
- The action checks that
index.htmlsits at the top of./buildbefore it uploads anything. Point it at the repository root by mistake and it stops with a clear message. - The upload is a full replace. Whatever is in the folder becomes the site, and anything missing from it is gone from the live site.
- Each run is kept as a numbered version you can see later.
- The job summary prints the live link, so you don't dig through logs.
We made site-id required on purpose. An action that quietly created a new site whenever the id was missing would make a fresh site on every push after one typo.
Build In Actions Or Let The Host Build?

Building in your own runner is the better choice when you want one portable workflow and full control of the build machine. Letting the host build is better when you want preview links and rollbacks with nothing to maintain. Both are reasonable, and I've used both.
| Build in GitHub Actions | Let the host build from Git | |
|---|---|---|
| Where the build runs | A GitHub runner you configure | The host's build machines |
| Who you pay in build minutes | GitHub, for private repositories | The host, out of its build allowance |
| Setup | One workflow file, plus a key | Connect the repository in the host's dashboard |
| Preview links per pull request | Possible, but you build it yourself | Usually included |
| Switching hosts later | Change one step | Reconnect, reconfigure the build |
| Secrets live in | GitHub | The host |
Netlify, Vercel and Cloudflare Pages all build from a connected repository, and their preview links per pull request are genuinely good. GitHub Pages is the odd one out, because its own recommended route for a framework build is an Actions workflow, so you end up writing this file anyway. what GitHub Pages does and does not do well goes into its limits.
OxyPages can also build from a connected GitHub repository now, on every plan, with branch and pull request previews. It's new, and GitHub is the only provider it connects to. If you're on GitLab or Bitbucket, the Action route doesn't help you either, so pick a host that builds from your provider.
When Neither Route Is Enough
A static upload can't help when a page has to be generated at the moment someone asks for it. Server rendering, API routes, middleware and Next.js image optimisation all need a host that runs code per request.
OxyPages doesn't run server-side code, so that kind of project belongs on Vercel, Netlify or Cloudflare Workers. Switch the framework to static output if you can, and if you can't, that's your answer.
What To Do Next
Fifteen minutes, in this order.
- Run
npm run buildlocally and note the folder it writes. Check it against the table above. - Open that folder and confirm
index.htmlis at the top, not one level down. - Copy the generic workflow into
.github/workflows/deploy.ymland push it. Watch the first run in the Actions tab. It should build and stop at the placeholder. - Add your host's key as a secret, swap in its upload step, and push again.
- Make a tiny visible change, push it, and time how long until it's live. That number is your feedback loop from now on.
If you'd like that folder live at a real address with no build pipeline on the hosting side, see what each plan includes. The action works on the free plan, and the help article on deploying from GitHub has the setup in one page.
FAQ
Can I Use A GitHub Actions Deploy With A Private Repository?
Yes. The workflow runs the same way in a private repository. The difference is billing: GitHub's docs, checked November 2026, say standard runners are free for public repositories, while private repositories draw on a monthly included allowance that depends on your GitHub plan.
Why Does My Workflow Upload My Whole Repository?
Because the upload step points at the repository root instead of the output folder. Set it to ./dist, ./build, ./out or ./_site, whichever your framework writes. A good upload step refuses to run when there's no index.html at the top of the folder.
Do I Need A Separate Build Step If The Host Builds For Me?
No. If your host builds from a connected repository, it runs the build itself, and a workflow would just build the site twice. Use Actions when you want the build under your control, or when your host only accepts finished files.
Can I Deploy A Next.js App With GitHub Actions?
Only as a static export. Set output: 'export' in your Next.js config, and the GitHub Actions deploy is then the same as for any other framework: build, and upload the out folder. If the app uses server rendering, API routes or middleware, it needs a host that runs Node or functions, such as Vercel.