OxyPages

A GitHub Actions Deploy For Any Static Framework Build

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:

  1. Check out the code. The runner copies your repository at the commit you pushed.
  2. Install Node. You pick the version, so the build matches your laptop.
  3. Install dependencies. npm ci installs exactly what your lock file says, nothing newer.
  4. Run the build. Usually npm run build, which writes the output folder.
  5. 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.

FrameworkBuild commandOutput folderWatch out for
Vite (React, Vue, Svelte, Solid)npm run builddistA single-page app needs its host to send unknown paths to index.html
Astro (default static output)npm run builddistServer output needs an adapter and a host that runs code
Next.jsnpm run build with output: 'export' setoutWithout that setting there is no out folder at all
Docusaurusnpm run buildbuildSet baseUrl if the site lives in a subfolder
SvelteKit with adapter-staticnpm run buildbuildA route that can't be prerendered fails the build unless you set a fallback page
Eleventynpx @11ty/eleventy_siteNone worth naming
Gatsbynpm run buildpublicpublic 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

Worked Example: A Docusaurus Site On Every Push - OxyPages

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.

  1. Create an API key in your account under Profile, then API Keys. Every plan can use one, including Free, once your email is verified.
  2. Add it as a repository secret, using the secret name the snippet below reads.
  3. Add your site id as a repository variable, not a secret. It isn't sensitive.
  4. 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.html sits at the top of ./build before 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?

Build In Actions Or Let The Host Build? - OxyPages

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 ActionsLet the host build from Git
Where the build runsA GitHub runner you configureThe host's build machines
Who you pay in build minutesGitHub, for private repositoriesThe host, out of its build allowance
SetupOne workflow file, plus a keyConnect the repository in the host's dashboard
Preview links per pull requestPossible, but you build it yourselfUsually included
Switching hosts laterChange one stepReconnect, reconfigure the build
Secrets live inGitHubThe 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.

  1. Run npm run build locally and note the folder it writes. Check it against the table above.
  2. Open that folder and confirm index.html is at the top, not one level down.
  3. Copy the generic workflow into .github/workflows/deploy.yml and push it. Watch the first run in the Actions tab. It should build and stop at the placeholder.
  4. Add your host's key as a secret, swap in its upload step, and push again.
  5. 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.

Try It Now

Still Here? Drop It In.

The whole pitch fits in one sentence: your HTML, on a link, in seconds.

Drag and drop your HTML files, a folder, a ZIP, your app's build folder, a PDF or an image

or ·

No account needed. Your unclaimed website stays live for 30 minutes on a free subdomain. Claim it to your account to keep it permanently.

  • No Account Needed
  • Free SSL
  • 30-Minute Unclaimed Link, Claim To Keep It