Deploying a private website from a GitHub source through GitLab's free private Pages hosting โ mirror push, GitLab CI build, private publish.
# ๐ GitHub โ GitLab Pages Pipeline: Deploying a Private Website from GitHub
**Date:** July 17, 2026
**Tags:** Pipelines, CI/CD, GitHub, GitLab, GitLab Pages, Astro, DevOps, AI / Pipelines
---
## ๐ฏ Overview
This guide documents how to run a **private website** sourced from **GitHub** but deployed through **GitLab Pages**. The trick: GitHub is the source of truth, but the *deployment* happens on GitLab's free Pages hosting (which can serve **private** sites to authenticated users โ something Cloudflare Pages does not do for free). The bridge is a **mirror push** from GitHub to GitLab, then a **GitLab CI** job that builds the static site and publishes it to GitLab Pages.
The whole flow is:
```
git push origin main (you push to GitHub)
โ
โผ
GitHub repo (ryanthemanr0x/website)
โ (mirror hook / scheduled push)
โผ
GitLab repo (gitlab.com//website)
โ detects new commit on main
โผ
GitLab CI (.gitlab-ci.yml)
โ runs: npm install โ npm run build
โผ
public/ (static output)
โ
โผ
GitLab Pages โ https://website-6768cb.gitlab.io
โ
โผ
Private site (visible to project members / logged-in users)
```
> โ ๏ธ **No secrets in the build.** All tokens below are `` text. The only real secret is the **GitLab deploy key / personal access token** used for the mirror push โ that lives in a GitHub Actions secret or a local cron, never in the repo.
---
## ๐งฉ Why GitLab Pages for a Private Site
| Host | Private site on free tier? | Source repo |
|------|---------------------------|-------------|
| **Cloudflare Pages** | โ No (public only on free) | GitHub direct |
| **GitHub Pages** | โ Yes (private repo โ private pages, limited) | GitHub only |
| **GitLab Pages** | โ Yes (project set to internal/private) | Any repo via mirror |
GitLab Pages is the cleanest way to get a **private** static site from a **GitHub** source: mirror the code over, let GitLab build + host it, and restrict visibility to project members.
---
## ๐ Prerequisites
- A **GitHub** repo with the site source (e.g. `github.com/ryanthemanr0x/website`).
- A **GitLab** account + empty project (e.g. `gitlab.com//website`).
- A **GitLab Personal Access Token** (``) with `write_repository` scope, OR a **deploy key**.
- A static site generator (this example uses **Astro**, but the CI is generator-agnostic).
> ๐ The GitHub repo stays the source of truth. The GitLab repo is a **mirror** โ never edit it directly.
---
## ๐ง Step 1 โ Create the GitLab Project
1. In GitLab, create a new project (do **not** initialize with a README โ it will be overwritten by the mirror).
2. Set **Visibility** to **Private** (or Internal) so the Pages site is not public.
3. Note the project path: `gitlab.com//website`.
4. Generate a PAT: **Preferences โ Access Tokens โ** scope `write_repository`, expires never (or rotate).
```bash
# Save the token somewhere safe (GitHub Actions secret, or a local env file)
# GITLAB_PAT=
```
---
## ๐ง Step 2 โ Add GitLab as a Mirror Remote on GitHub
The simplest bridge is a **GitHub Actions workflow** that pushes every commit to GitLab. Store the GitLab PAT as a **GitHub Actions secret** named `GITLAB_PAT`.
```yaml
# .github/workflows/mirror-to-gitlab.yml
name: Mirror to GitLab
on:
push:
branches: [main]
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to GitLab
env:
GITLAB_PAT: ${{ secrets.GITLAB_PAT }}
run: |
git config --global user.email "bot@users.noreply.github.com"
git config --global user.name "mirror-bot"
git remote add gitlab \
https://oauth2:${GITLAB_PAT}@gitlab.com//website.git
git push gitlab main --force
```
> ๐ก `--force` is safe here because GitLab is a pure mirror. If you prefer, use a **mirror push** (`git push --mirror`) to keep branches/tags in sync.
---
## ๐ง Step 3 โ GitLab CI Builds and Publishes to Pages
GitLab Pages requires a `.gitlab-ci.yml` that builds the site into `public/` and uses the `pages` job artifact. The job name **must** be `pages` and the artifact path **must** be `public/`.
```yaml
# .gitlab-ci.yml (in the GitLab mirror repo)
stages:
- build
- deploy
build-site:
stage: build
image: node:20
script:
- npm install
- npm run build
artifacts:
paths:
- public
only:
- main
pages:
stage: deploy
script:
- echo "Publishing public/ to GitLab Pages"
artifacts:
paths:
- public
only:
- main
```
> โ ๏ธ **Astro output:** Astro builds to `dist/` by default. Either set `outDir: 'public'` in `astro.config.mjs`, or add a `mv dist public` step in the CI script. The example assumes `public/` is the final output.
---
## ๐ง Step 4 โ Enable GitLab Pages (Private)
1. In GitLab: **Settings โ Pages** โ ensure **Pages** is enabled.
2. Because the project is **Private**, the Pages site at `https://website-6768cb.gitlab.io` is only visible to logged-in project members.
3. The live URL follows the pattern `https://-.gitlab.io` (GitLab auto-assigns the ``).
```bash
# Verify the site is served
curl -I https://website-6768cb.gitlab.io
# -> 200 for authenticated members, 404/302 for anonymous (private)
```
---
## ๐ Making It Private (Key Difference vs Cloudflare)
| Setting | Value |
|---------|-------|
| Project visibility | **Private** or **Internal** |
| Pages access | Inherits project visibility โ non-members get nothing |
| Custom domain | Optional (add under Settings โ Pages โ New domain) |
| Force HTTPS | Enabled by default on `*.gitlab.io` |
This is the whole reason to route through GitLab instead of Cloudflare: **free private Pages**. Cloudflare Pages only serves public sites on the free tier.
---
## ๐งช Smoke Test
```bash
# 1. Push to GitHub
git push origin main
# 2. GitHub Action mirrors to GitLab (check Actions tab)
# 3. GitLab CI runs build + pages job (check CI/CD โ Pipelines)
# 4. Site live (as a project member):
open https://website-6768cb.gitlab.io
# 5. Confirm privacy: open in incognito โ should NOT show the site
```
---
## ๐ฉบ Troubleshooting
| Symptom | Likely cause | Fix |
|---------|-------------|-----|
| Mirror push 401 | Bad/expired `GITLAB_PAT` | Regenerate PAT with `write_repository`; update GitHub secret |
| Pages job missing | Job not named `pages` | Rename job to `pages`; artifact path must be `public` |
| 404 on the site | Project is Public but Pages disabled | Enable Pages under Settings โ Pages |
| Site public when it should be private | Project visibility wrong | Set project to Private/Internal |
| Build fails on `npm run build` | Node version / deps | Pin `image: node:20`; run `npm ci` |
---
## ๐ Security Checklist
- [ ] `GITLAB_PAT` is a GitHub Actions **secret**, never committed.
- [ ] GitLab repo is a **mirror** โ no direct edits.
- [ ] Project visibility set to **Private/Internal** for a private site.
- [ ] No API keys in the static output (all ``).
- [ ] `npm ci` (not `npm install`) in CI for reproducible builds.
---
## โ Summary
You now have a **private website** deployed through a **GitHub โ GitLab Pages** pipeline:
- **GitHub** is the source of truth (`ryanthemanr0x/website`).
- A **GitHub Action** mirrors every push to **GitLab** using a PAT secret.
- **GitLab CI** (`.gitlab-ci.yml`) builds the static site into `public/`.
- The `pages` job publishes it to **GitLab Pages** at `https://website-6768cb.gitlab.io`.
- Because the GitLab project is **private**, the site is only visible to authenticated members โ free private hosting that Cloudflare Pages cannot do.
This complements the [Cloudflare Pages pipeline](/blog/github-cloudflare-pipeline/) used for the public blog: public docs on Cloudflare, private sites on GitLab. ๐
---
*No API keys, tokens, or secrets are included in this guide โ every value is a `` you supply locally. Built with Astro, GitHub Actions, and GitLab CI/CD.*