By the end of this tutorial you'll have a Git repository with a .gitlab-ci.yml pipeline that automatically builds a static site and publishes it on every push to the default branch — to GitLab Pages by default, or to a directory on your own server over rsync/SSH as an alternative.
Prerequisites
A GitLab.com account (or access to a self-managed GitLab instance) with permission to create a new project
Git and Node.js/npm installed on your local machine
Basic familiarity with the command line and Git (clone, add, commit, push)
Basic familiarity with YAML syntax
For the SSH/rsync alternative in Step 5: a separate Ubuntu 22.04 LTS server reachable over SSH as a non-root sudo user, with a web server such as Nginx already configured to serve static files from a directory you control
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
A static site — plain HTML, CSS, and JavaScript with no server-side runtime — is one of the simplest things to host, but "simple to host" and "simple to deploy correctly every time" are not the same problem. Building locally and dragging files onto a server by hand works until someone forgets to run the build step, uploads a stale file, or overwrites a change a teammate just pushed. A CI/CD pipeline removes the human step: every push to your repository triggers the same build, in the same environment, followed by the same deploy, with no laptop-specific state involved.
This tutorial builds a GitLab CI/CD pipeline, defined entirely in a .gitlab-ci.yml file at the root of a repository, that installs dependencies, runs a build command, and publishes the result. It covers two deploy targets. The primary path is GitLab Pages, which needs no server of your own — GitLab builds and serves the site directly, with TLS included, as soon as a correctly-named job succeeds. The alternative path, for when you need the site on infrastructure you already run, copies the build output to a directory on a server you control using rsync over SSH, authenticated with a dedicated deploy key stored as a masked, protected GitLab CI/CD variable.
The build step itself is written generically, using a single npm script that stands in for whatever static site generator you actually use — Eleventy, Astro, Hugo, Jekyll, or a plain bundler. Everything downstream of that script (the pipeline configuration, the artifact handling, both deploy jobs) applies unchanged regardless of which generator produced the files.
Step 1 — Creating a GitLab Project and a Minimal Static Site
In GitLab (GitLab.com or a self-managed instance), create a new blank project — from the top navigation choose New project > Create blank project, give it a name such as static-site-demo, and set its visibility. Clone the empty repository to your local machine.
bash
git clone [email protected]:your-namespace/static-site-demo.git
cd static-site-demo
Scaffold a minimal Node-based project. This tutorial's "build" is intentionally trivial — it copies files from src/ into dist/ — so the pipeline configuration stays the focus rather than any one generator's CLI. In a real project, replace the single line in the build script below with your generator's actual build command (for example hugo --minify, npx @11ty/eleventy, or astro build); everything else in this tutorial is unaffected by that swap.
Run npm install once, even though the project has no dependencies yet — this generates a package-lock.json file, and the pipeline uses npm ci in Step 3, which requires a lockfile to be present in the repository. Then run the build locally as a sanity check.
bash
npm install
npm run build # produces ./dist
Commit everything, including the lockfile, and push to the default branch.
bash
git add .
git commit -m "Initial static site scaffold"
git push origin main
GitLab automatically picks up a file named .gitlab-ci.yml at the root of a repository and uses it to define a pipeline: a set of jobs, grouped into stages that run in order (all jobs in one stage must finish before the next stage starts). Each job runs in its own isolated container, defined by an image: key, and jobs within the same stage run in parallel by default.
On GitLab.com, shared runners are enabled for every new project automatically, so pushing a valid .gitlab-ci.yml is enough to see a pipeline execute — no separate runner setup is required. On a self-managed instance, confirm with your administrator that at least one runner is registered and enabled for your project, under Settings > CI/CD > Runners; without one, pipelines queue indefinitely and never start.
Files produced by a job — declared under that job's artifacts:paths key — are, by default, automatically made available to every job in a later stage of the same pipeline. That's how the build output produced in one job reaches the deploy job later in this tutorial without any manual copying between jobs.
Step 3 — Writing the Build Job
Create .gitlab-ci.yml at the repository root. Define two stages and a build job that installs dependencies and runs the build script.
npm ci, unlike npm install, installs exactly what's locked in package-lock.json and refuses to run if the lockfile and package.json are out of sync — the correct choice for CI, where reproducibility matters more than convenience. The artifacts:paths entry tells GitLab to save the dist/ directory once the job finishes and hand it to jobs in later stages; expire_in cleans the artifact up after an hour, since only the very next job in this pipeline needs it.
“Optional: to avoid reinstalling npm dependencies on every pipeline run, add caching keyed to the lockfile under the build job: cache: { key: { files: [package-lock.json] }, paths: [node_modules/] }. GitLab invalidates the cache automatically whenever package-lock.json changes.”
In the project sidebar, open Build > Pipelines (older GitLab versions: CI/CD > Pipelines) to watch the pipeline run. The build job should turn green; expand its log if it doesn't to see the failing command.
Step 4 — Deploying to GitLab Pages
GitLab Pages activates for a project automatically once a pipeline contains a job literally named pages whose artifacts include a directory literally named public — no separate hosting configuration is required, and GitLab issues TLS for the resulting *.gitlab.io URL itself. Add this job to .gitlab-ci.yml.
Because this job runs in the deploy stage, it automatically receives the dist/ artifact produced by the build job in the earlier stage — nothing extra needs to be declared for that. Its script simply relocates the already-built files into a directory named public, the one hardcoded name GitLab Pages checks for. The rules block restricts actual publishing to pushes on your default branch: a pipeline triggered by a merge request or a feature branch still runs the build job (so you get build failures early), but only a push or merge to the default branch updates the live site.
“On self-managed GitLab instances, an administrator must enable Pages support instance-wide (and configure a wildcard domain for it) before a pages job can publish anything. If your project's sidebar has no Deploy > Pages entry after this job succeeds, that's the likely cause — ask your instance administrator.”
Push the change, wait for the pipeline to finish, then open Deploy > Pages in the left sidebar (older GitLab versions: Settings > Pages) to find the published URL. On GitLab.com it typically follows the pattern https://<namespace>.gitlab.io/<project-slug>. Whether the published site is reachable by anyone or only by people who can already access the project follows the project's own visibility setting under Settings > General > Visibility.
“A freshly published or updated Pages site can take a minute or two to propagate after the pipeline finishes — an immediate 404 right after a successful pipeline isn't necessarily a misconfiguration.”
Step 5 — Alternative: Deploying to Your Own Server over SSH and rsync
GitLab Pages only serves files over its own domain or subdomain. If the site needs to live on infrastructure you already run — behind an existing reverse proxy, alongside other services on the same host, or simply because Pages isn't available on your instance — deploy with rsync over SSH instead. This section assumes a separate Ubuntu 22.04 LTS server, reachable over SSH as a non-root sudo user, with a web server such as Nginx already configured to serve static files from a directory such as /var/www/example.com/html.
Generate a dedicated deploy key
Don't reuse your personal SSH key for CI/CD. Generate a new key pair dedicated to this pipeline, with no passphrase — the pipeline can't type one interactively.
This produces two files: the private key gitlab_ci_deploy_key and the public key gitlab_ci_deploy_key.pub. On the server, using your existing sudo access, create a dedicated, unprivileged user for deployments and its SSH directory.
The deploy user has no password set (--disabled-password), so you can't SSH into it directly to install the key. Instead, pipe the public key through your own existing SSH session and append it as sudo.
bash
cat gitlab_ci_deploy_key.pub | ssh [email protected] "sudo tee -a /home/deploy/.ssh/authorized_keys"
Then, on the server, fix ownership and permissions on the file you just created.
“sshd enforces strict permission checks on authorized_keys and its parent directory by default (the StrictModes setting) and silently refuses a key if that file or directory is group- or world-writable. After editing these as another user, always confirm chmod 700 on ~/.ssh and chmod 600 on authorized_keys.”
Store the private key as a protected CI/CD variable
GitLab's variable masking only accepts single-line values, and a private key spans multiple lines, so encode it as base64 first.
bash
base64 -w0 gitlab_ci_deploy_key
“On macOS, base64 doesn't support -w; use base64 -i gitlab_ci_deploy_key | tr -d '\n' instead to get the same single unwrapped line.”
Copy that single line of output. In the project, go to Settings > CI/CD > Variables > Add variable, and create the following:
SSH_PRIVATE_KEY — the base64 output from the previous command; check both Protect variable and Mask variable
DEPLOY_USER — deploy
DEPLOY_HOST — example.com
DEPLOY_PATH — /var/www/example.com/html
Protect variable restricts a variable to pipelines running on protected branches or protected tags. Check Settings > Repository > Protected branches and confirm your default branch (usually main) is listed — otherwise a pipeline on that branch runs with these variables silently unset rather than failing with a clear error.
Write the deploy job
Add a deploy job to .gitlab-ci.yml. Use it in place of, or alongside, the pages job from Step 4.
before_script installs an SSH client and rsync into the minimal Alpine image (neither ships by default), starts an in-memory ssh-agent, decodes the base64-encoded key straight into that agent without ever writing it to disk, and records the server's host key with ssh-keyscan so rsync doesn't stop on an interactive host-authenticity prompt — which would hang the job, since nothing is present to answer it.
script runs rsync -avz --delete, which mirrors dist/ onto the server: -a preserves permissions and timestamps (archive mode), -z compresses the transfer, and --delete removes any file on the server that no longer exists in the build output, so removed pages don't linger. The trailing slash on dist/ matters — it copies the directory's contents into DEPLOY_PATH, not the directory itself.
environment gives this job's deployments their own entry under Deploy > Environments in the sidebar, with a link to the live URL and a history of which pipeline deployed which commit.
“If dist/ is empty when this job runs, confirm the build job still runs before deploy in the same pipeline and that no dependencies: [] on the deploy job is excluding its artifacts.”
In practice you'd choose one of Step 4 (Pages) or this rsync job as your deploy target — running both under the deploy stage is fine only if you genuinely want the site published to two places at once.
In Build > Pipelines, watch the new pipeline run the build job followed by the deploy job. Once the deploy job's status is green, reload the GitLab Pages URL or the rsync target's URL and confirm the change appears — within roughly a minute for Pages, effectively immediately for rsync since it writes straight into the live directory.
“Because the deploy/pages job's rules key off $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH, pushing to a feature branch or opening a merge request still runs the build job for early feedback without touching the live site. Only a push or merge to the default branch publishes.”
Conclusion
You now have a .gitlab-ci.yml pipeline that builds a static site on every push and publishes it automatically — either to GitLab Pages, with no external server involved and TLS included, or to a directory on a self-managed Ubuntu server over rsync, authenticated with a dedicated SSH key stored as a masked, protected GitLab CI/CD variable. In both cases, publishing is restricted to the default branch, so feature branches and merge requests get build feedback without ever touching the live site.