Skip to content

🚀 DevOps - GitLab deployment

Overview

This procedure automatically deploys the main branch to a Linux server after a push.

The GitLab runner connects to the server over SSH and executes a deployment script. The server then fetches the latest commit from GitLab, validates the Docker Compose configuration, rebuilds the application images, and recreates the affected containers.

Two separate SSH key pairs are used:

  1. a CI deployment key for the GitLab runner to access the Linux server;
  2. a GitLab deploy key for the Linux server to access the private repository.

The deployment uses git fetch and git reset --hard instead of git pull to ensure that the server matches origin/main exactly. Any tracked modifications made directly on the server will be discarded.

This procedure does not provide automatic rollback or zero-downtime deployment.

Requirements

Define the project and deployment settings before running the setup commands:

# define the deployment settings
PROJECT="opendata"
DEPLOY_USER="debian"
DEPLOY_HOST="gpt.kurrant.com"
DEPLOY_PORT="22"
REPOSITORY_SSH="git@gitlab.com:kurrant/luwa/opendata.git"

Configure server access to GitLab

The server needs a read-only GitLab deploy key because it fetches the private repository over SSH.

Run these commands as the deployment user, not as root.

Generate the GitLab deploy key

# verify the project variable
[ -n "${PROJECT:-}" ] || { echo "project is not set" >&2; exit 1; }

# create the ssh directory
mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"

# generate the repository deploy key
ssh-keygen \
  -t ed25519 \
  -N "" \
  -C "deploy-${PROJECT}-server-to-gitlab" \
  -f "$HOME/.ssh/gitlab_${PROJECT}_deploy_key"

# protect the key files
chmod 600 "$HOME/.ssh/gitlab_${PROJECT}_deploy_key"
chmod 644 "$HOME/.ssh/gitlab_${PROJECT}_deploy_key.pub"

# display the public key
cat "$HOME/.ssh/gitlab_${PROJECT}_deploy_key.pub"

The private key must remain on the Linux server.

Register the deploy key in GitLab

Open:

project → settings → repository → deploy keys → add new key

Configure the key:

title: staging/prod - PROJECT deployment server
key: paste the content of gitlab_deploy_key.pub
grant write permissions: unchecked
expiration date: optional but recommended

Read access is sufficient because the server only runs git fetch.

Register the GitLab host key

Collect the GitLab host key:

# collect the gitlab host key
ssh-keyscan -t ed25519 gitlab.com 2>/dev/null > /tmp/gitlab_known_hosts

# display the collected fingerprint
ssh-keygen -lf /tmp/gitlab_known_hosts

Compare the fingerprint with the fingerprint published by GitLab before installing it.

# install the verified gitlab host key
touch "$HOME/.ssh/known_hosts"
cat /tmp/gitlab_known_hosts >> "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
rm -f /tmp/gitlab_known_hosts

Do not accept an unverified host key automatically.

Test GitLab authentication

# test repository authentication
ssh \
  -T \
  -i "$HOME/.ssh/gitlab_${PROJECT}_deploy_key" \
  -o IdentitiesOnly=yes \
  -o StrictHostKeyChecking=yes \
  git@gitlab.com

Clone the repository

# verify the project variable
[ -n "${PROJECT:-}" ] || { echo "project is not set" >&2; exit 1; }

# define the application directory
APP_DIR="${HOME}/${PROJECT}"

# clone the repository when required
if [ ! -d "$APP_DIR/.git" ]; then
  GIT_SSH_COMMAND="ssh -i $HOME/.ssh/gitlab_${PROJECT}_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" \
    git clone "$REPOSITORY_SSH" "$APP_DIR"
fi

# configure the repository ssh key
git -C "$APP_DIR" config core.sshCommand \
  "ssh -i $HOME/.ssh/gitlab_${PROJECT}_deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes"

Verify that the repository can be updated:

# test the repository fetch
git -C "$APP_DIR" fetch origin main

Configure CI access to the server

The GitLab runner needs a separate SSH key pair to connect to the Linux server.

Generate this key on a trusted administration workstation.

Generate the CI deployment key

# verify the project variable
[ -n "${PROJECT:-}" ] || { echo "project is not set" >&2; exit 1; }

# define the local key path
KEY_FILE="./gitlab_ci_${PROJECT}_server"

# generate the ci deployment key
ssh-keygen \
  -t ed25519 \
  -N "" \
  -C "gitlab-ci-${PROJECT}-runner-to-server" \
  -f "$KEY_FILE"

This creates:

gitlab_ci_opendata_server
gitlab_ci_opendata_server.pub

The private key will become SSH_PRIVATE_KEY.

The public key must be installed on the Linux server.

Install the public key on the server

# install the ci public key
ssh-copy-id \
  -i "${KEY_FILE}.pub" \
  -p "$DEPLOY_PORT" \
  "$DEPLOY_USER@$DEPLOY_HOST"

Test the connection:

# test the ci ssh key
ssh \
  -i "$KEY_FILE" \
  -p "$DEPLOY_PORT" \
  -o IdentitiesOnly=yes \
  "$DEPLOY_USER@$DEPLOY_HOST"

Use one CI key per environment where possible. Do not reuse the server-to-GitLab deploy key.

Generate SSH_KNOWN_HOSTS

SSH_KNOWN_HOSTS contains the Linux server's public SSH host key. It is not the CI public key and is not a private secret.

First, display the server's ED25519 host-key fingerprint directly on the server:

# display the server host fingerprint
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

Then collect the same key from a trusted administration workstation:

# collect the server host key
ssh-keyscan \
  -p "$DEPLOY_PORT" \
  -t ed25519 \
  "$DEPLOY_HOST" \
  2>/dev/null > ssh_known_hosts

# display the collected fingerprint
ssh-keygen -lf ssh_known_hosts

Compare both fingerprints. They must be identical.

The hostname and port used by ssh-keyscan must match DEPLOY_HOST and DEPLOY_PORT exactly.

Do not run ssh-keyscan inside the CI job because this would trust whichever server responds during the pipeline.

Configure GitLab CI/CD variables

Open:

project → settings → ci/cd → variables

Protect the main branch before enabling protected variables.

SSH_PRIVATE_KEY

key: SSH_PRIVATE_KEY
type: file
value: full content of gitlab_ci_opendata_server
visibility: visible
protect variable: enabled
environment scope: staging
expand variable reference: disabled

The private key must:

  • include the BEGIN and END lines;
  • use Unix LF line endings;
  • end with a final newline;
  • never be printed in the pipeline log.

For a File variable, $SSH_PRIVATE_KEY contains the path to GitLab's temporary private-key file.

SSH_KNOWN_HOSTS

key: SSH_KNOWN_HOSTS
type: file
value: full content of ssh_known_hosts
visibility: visible
protect variable: enabled
environment scope: staging
expand variable reference: disabled

For a File variable, $SSH_KNOWN_HOSTS contains the path to GitLab's temporary known-hosts file.

Server variables

Add these regular variables:

key: DEPLOY_HOST
value: server.example.com
type: variable
protect variable: enabled
environment scope: staging
key: DEPLOY_PORT
value: 22
type: variable
protect variable: enabled
environment scope: staging
key: DEPLOY_USER
value: debian
type: variable
protect variable: enabled
environment scope: staging

Configure the GitLab pipeline

Create .gitlab-ci.yml in the repository:

stages:
  - deploy

deploy_staging:
  stage: deploy
  image: alpine:3.22
  timeout: 15m

  variables:
    GIT_STRATEGY: "none"
    PROJECT: "opendata"

  before_script:
    - apk add --no-cache openssh-client
    - test -n "${DEPLOY_HOST:-}" || { echo "deploy host is not set" >&2; exit 1; }
    - test -n "${DEPLOY_PORT:-}" || { echo "deploy port is not set" >&2; exit 1; }
    - test -n "${DEPLOY_USER:-}" || { echo "deploy user is not set" >&2; exit 1; }
    - test -s "${SSH_PRIVATE_KEY:-}" || { echo "ssh private key is missing or empty" >&2; exit 1; }
    - test -s "${SSH_KNOWN_HOSTS:-}" || { echo "ssh known hosts is missing or empty" >&2; exit 1; }
    - export SSH_KEY_FILE="$HOME/.ssh/deploy_key_${PROJECT}"
    - install -m 700 -d "$HOME/.ssh"
    - install -m 644 "$SSH_KNOWN_HOSTS" "$HOME/.ssh/known_hosts"
    - chmod 400 "$SSH_PRIVATE_KEY"
    - ssh-keygen -y -f "$SSH_PRIVATE_KEY" >/dev/null || { echo "ssh private key is invalid" >&2; exit 1; }
  script:
    - >
      ssh
      -p "$DEPLOY_PORT"
      -i "$SSH_PRIVATE_KEY"
      -o BatchMode=yes
      -o ConnectTimeout=15
      -o IdentitiesOnly=yes
      -o StrictHostKeyChecking=yes
      "$DEPLOY_USER@$DEPLOY_HOST"
      "/usr/local/bin/deploy-${PROJECT}"

  environment:
    name: prod
    deployment_tier: production

  resource_group: opendata-prod

  rules:
    - if: '$CI_COMMIT_BRANCH == "main" && $CI_PIPELINE_SOURCE == "push"'
      when: on_success
    - when: never

GIT_STRATEGY: "none" is intentional because the job does not need a local checkout. The Linux server fetches the repository itself.

The original ~/.ssh/deploy_key file is no longer needed. The pipeline uses the temporary file referenced by $SSH_PRIVATE_KEY directly.

The pipeline runs only for pushes to main. Manual, scheduled, merge-request, and tag pipelines will not deploy with these rules.

Create the server deployment script

Run this setup command after defining PROJECT.

# verify the project variable
[ -n "${PROJECT:-}" ] || { echo "project is not set" >&2; exit 1; }

# create the deployment script
sudo tee "/usr/local/bin/deploy-${PROJECT}" >/dev/null <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail

APP_DIR="/home/debian/${PROJECT}"
ENV_FILE="\${APP_DIR}/.env"
COMPOSE_FILE="\${APP_DIR}/compose.yml"

echo "deploying ${PROJECT}"

cd "\${APP_DIR}"

echo "fetching main branch"
git fetch --prune origin main

echo "updating working tree"
git reset --hard origin/main

echo "validating docker compose configuration"
docker compose \
  --project-directory "\${APP_DIR}" \
  --env-file "\${ENV_FILE}" \
  -f "\${COMPOSE_FILE}" \
  config >/dev/null

echo "building and starting containers"
docker compose \
  --project-directory "\${APP_DIR}" \
  --env-file "\${ENV_FILE}" \
  -f "\${COMPOSE_FILE}" \
  up -d --build --remove-orphans

echo "container status"
docker compose \
  --project-directory "\${APP_DIR}" \
  --env-file "\${ENV_FILE}" \
  -f "\${COMPOSE_FILE}" \
  ps

echo "deployment completed"
EOF

# set the script permissions
sudo chmod 755 "/usr/local/bin/deploy-${PROJECT}"

Test the server deployment

Execute the script as the deployment user:

# test the deployment script
sudo \
  -u "$DEPLOY_USER" \
  -H \
  "/usr/local/bin/deploy-${PROJECT}"

Verify:

  • the repository is updated to origin/main;
  • the Compose configuration is valid;
  • images are rebuilt;
  • containers are running or healthy;
  • the application responds correctly.

Security notes

Keep the following files and values private:

gitlab_ci_opendata_server
~/.ssh/gitlab_opendata_deploy_key
.env

The following values are public and can be distributed:

gitlab_ci_opendata_server.pub
~/.ssh/gitlab_opendata_deploy_key.pub
ssh_known_hosts