Lina Brihoum
website development

Hosting and automating a website

Hosting and automating a website
8 min read
website development

Exploring Website Hosting Methods and Automation in Backend, Hosting, and CI/CD

Introduction

For teams using Git and GitHub, GitHub Actions offers a seamless way to automate everything between git push and a live website. But before automating a deployment, it's worth being deliberate about what kind of deployment you're automating — because "hosting a website" is really three different problems depending on how your site is built, and the right pipeline follows from that choice. This guide covers the hosting models and their trade-offs, then builds a real GitHub Actions pipeline for a static Next.js site — including the caching, permissions, and concurrency details that separate a workflow that works from one that works well.

GitHub Actions deploy

First Decision: What Are You Actually Hosting?

Modern websites deploy in one of three fundamental shapes, and everything downstream — cost, performance, pipeline design — follows from which one you pick.

Static hosting means your build step produces plain files (HTML, CSS, JS) and a CDN serves them. There is no server executing your code at request time, which means there is nothing to patch, nothing to scale, effectively nothing to attack, and hosting that is free or nearly so (GitHub Pages, Cloudflare Pages, S3 + CloudFront). The constraint: everything dynamic must happen either at build time (pre-rendered pages) or in the browser (client-side fetches to APIs). Blogs, portfolios, documentation, and marketing sites live here happily — this site does.

Serverless/hybrid hosting (Vercel, Netlify, AWS Amplify) runs your framework's server features — server-side rendering, API routes, incremental static regeneration — as managed functions, billed per use. You keep dynamic capability without operating servers. The trade-offs are real but manageable: cold starts add latency to infrequently-hit routes, costs scale with traffic in ways worth watching, and you take a dependency on the platform's conventions. This is the default choice for Next.js applications that outgrow pure static export — and it's why this site eventually moved to Vercel.

Server hosting (a VPS, containers, Kubernetes) means you run the process. Full control, persistent connections (WebSockets), long-running jobs, any runtime you want — and in exchange, patching, scaling, TLS renewal, and 3am responsibility are yours. The honest guidance: choose this when a requirement forces it, not as a default. A surprising amount of the internet is a static site wearing an expensive server costume.

The rest of this post builds the pipeline for the first model, with notes where the others differ.

Why GitHub Actions?

Integrated Ecosystem

GitHub Actions is deeply integrated with GitHub: commits, pull requests, build results, and deployments all live in one place, with no external CI service to wire up and no separate credentials to manage. For the pull-request workflow specifically, the integration is the feature — status checks gate merges, and deploy previews attach to the PR conversation.

Customizable Workflows

Workflows are YAML files in .github/workflows/, triggered by repository events (push, pull_request, schedules, manual dispatch) and composed from reusable actions in a large marketplace. Whether it's building a Next.js application, running tests, or deploying to any hosting provider, the same primitives apply.

Reduced Overhead

Automation removes the human from the repetitive path: the moment code is pushed, workflows lint, test, build, and deploy it. The consistency matters more than the convenience — an automated deploy is the same every time, which is precisely what manual deploys are not. Every serious outage postmortem that includes "the deploy was done by hand that day" makes the same argument.

The Importance of Automating the Deployment Process

Automating deployment with CI/CD is a strategic advantage, not a convenience: consistent deployments, a stable release process, and the fast feedback loops that agile development depends on. For static sites, automation means updates go live minutes after merge — keeping content fresh with zero ceremony. And critically, automation makes deploys boring, which is the correct emotional state for a deploy.

Building the Pipeline

Step 1: Prepare Your Next.js Application

Configure Next.js for static export. In modern Next.js (13.3+), the old next export command is replaced by a config option:

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export", // static HTML export to ./out at build time
  images: { unoptimized: true }, // no server = no runtime image optimizer
};
 
module.exports = nextConfig;

The images line is the classic gotcha: Next's default image optimization runs on a server at request time, which a static export doesn't have — builds fail without this flag. This is also your reminder of the model's boundary: the moment you need genuine server-side rendering or API routes, you've outgrown output: "export" and belong on the serverless model instead.

Step 2: Set Up GitHub Actions

Create .github/workflows/deploy.yml. Here is a production-shaped workflow, followed by why each non-obvious line exists:

deploy.yml
name: Deploy Next.js Site
 
on:
  push:
    branches: [main]
  workflow_dispatch: # manual runs from the Actions tab
 
permissions:
  contents: read
  pages: write
  id-token: write
 
concurrency:
  group: pages-deploy
  cancel-in-progress: true
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm # caches ~/.npm keyed on package-lock.json
 
      - name: Restore Next.js build cache
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx') }}
          restore-keys: nextjs-${{ hashFiles('package-lock.json') }}-
 
      - name: Install dependencies
        run: npm ci # clean install from the lockfile — never plain `npm install` in CI
 
      - name: Build
        run: npm run build
 
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./out
 
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

The details that earn their lines:

  • permissions is a security boundary. The default GITHUB_TOKEN historically had broad write access; declaring the minimum (read code, write Pages, mint an OIDC token) is the workflow-level version of least privilege.
  • concurrency with cancel-in-progress prevents the classic race: two quick pushes, two parallel deploys, and the older one finishing last — silently shipping stale code. One group, latest wins.
  • npm ci, never npm install. ci installs exactly the lockfile — reproducible builds — and fails loudly if lockfile and manifest disagree, which is a bug you want to hear about in CI rather than discover in production.
  • Two caches, two purposes. The setup-node cache skips re-downloading packages; the .next/cache cache lets Next.js skip recompiling unchanged pages — together they routinely cut build times by half or more, which matters compounded over hundreds of deploys.
  • Build and deploy as separate jobs means the deploy step touches only a finished artifact — and the environment block gives you a deployment history and the option of protection rules (required reviewers for production) later, without restructuring.

Step 3: Deploying Elsewhere

The build half of the workflow is portable; only the last step changes. Deploying to Vercel or Netlify, you typically don't write this workflow at all — their GitHub integrations build on push and, more valuably, deploy preview environments for every pull request, which changes review culture: reviewers click the deployed branch instead of imagining it. Deploying to S3 + CloudFront, the final step becomes aws s3 sync ./out s3://bucket plus a CloudFront invalidation, authenticated via OIDC (aws-actions/configure-aws-credentials with a role, no long-lived keys in secrets — this is what the id-token: write permission enables). The pattern generalizes: artifact, credentials via OIDC, provider CLI.

Step 4: Commit and Push

git add .github/workflows/deploy.yml
git commit -m "ci: add GitHub Actions deployment pipeline"
git push origin main

The first run appears in the Actions tab; every subsequent push to main deploys automatically.

Extending the Pipeline

Once the deploy works, the pipeline is where quality gates accumulate — each one a pull-request check rather than a production surprise: npm run lint and tsc --noEmit before the build; Lighthouse CI to catch performance and accessibility regressions with budgets ("fail if LCP exceeds 2.5s"); a link checker for content sites, because dead links rot silently; and scheduled dependency updates via Dependabot or Renovate so security patches arrive as small reviewable PRs instead of a quarterly upgrade crisis. None of these require new infrastructure — they are steps in a file you already have.

Conclusion

You've now got more than a deployment script — you've got the shape of a real delivery pipeline: a deliberate hosting model, a reproducible build (npm ci, pinned Node, cached correctly), least-privilege permissions, race-free deploys, and a clear seam where quality gates and other providers slot in. The specifics will vary with your host and your framework's features, but the principles — static until forced otherwise, automate everything after the push, make deploys identical and boring — transfer to every project you'll ship. Adjust the workflow as needed to fit your setup, and let the robots do the deploying.