⚙️ CI/CD Pipeline Standards
Audience: DevOps Leads / Backend Engineers Purpose: Standard Operating Procedure (SOP) for configuring automated continuous deployment pipelines for new repositories.
At Apollyon, we utilize a Push-to-Deploy model via GitLab CI/CD. To protect the bare-metal host, we strictly use the Docker + SSH methodology. The GitLab Runner spins up an ephemeral Alpine Linux container, injects a masked SSH key, logs into the server as the app-runner service account, executes the update, and destroys itself.
The 1-to-1 Key Rule
Never reuse CI/CD SSH keys across different repositories. Every project must generate its own unique SSH key pair, so one project's access can be revoked (delete its line from app-runner's authorized_keys) without touching the others, and a leak is traced to one repository. Note that every key logs in as app-runner, which owns every folder in /opt/: a leaked key is a server-wide incident, so rotate it at once.
1. Server-Side: Key Provisioning
When setting up a new repository (e.g., a new client API or frontend), you must first create its dedicated access key on the host.
-
Generate the Key: Log in with your personal admin user and create a passphrase-less key named after the project:
ssh-keygen -t ed25519 -C "gitlab-ci-<project-name>" -f ~/.ssh/id_ci_<project-name> -
Authorize the Key: Copy the public key and append it to the service account's authorized list:
cat ~/.ssh/id_ci_<project-name>.pub # Switch context sudo -u app-runner -H bash nano ~/.ssh/authorized_keys -
Encode and Destroy: To safely store the private key in GitLab, it must be Base64 encoded to remove line breaks.
# Copy the output of this command: cat ~/.ssh/id_ci_<project-name> | base64 -w 0 # Destroy the unencrypted file rm ~/.ssh/id_ci_<project-name>
2. GitLab-Side: Variable Configuration
Navigate to the new repository in GitLab ➡️ Settings ➡️ CI/CD ➡️ Variables. Variables belong to the project: a new repository needs its own, even when another project already has variables with the same names (unless they are set on a parent group, where the project's page lists them as inherited).
| Variable | Value | Flags |
|---|---|---|
SSH_PRIVATE_KEY |
The Base64 string from step 1 (a key made for this repository only) | Masked and Protected |
SERVER_IP |
The server's address (an IP or a host name) | Visible, not protected |
SSH_KNOWN_HOSTS |
The server's host key lines (see below) | Protected, visible (it is a public key; GitLab cannot mask multi-line values) |
Protected variables only reach pipelines on protected branches and tags, so protect main (Settings ➡️ Repository ➡️ Protected branches) and any v* release tags.
Getting SSH_KNOWN_HOSTS
The three variables answer three different questions: SERVER_IP says where CI connects, SSH_PRIVATE_KEY proves who CI is to the server, and SSH_KNOWN_HOSTS proves who the server is to CI. Without it, a job that runs ssh-keyscan at deploy time trusts whatever machine answers at that address.
-
From your laptop (not the server), scan exactly the value of
SERVER_IP: each line is tied to that host string.ssh-keyscan -H <SERVER_IP> 2>/dev/null > known_hosts.txt ssh-keygen -lf known_hosts.txt # the fingerprints that answered -
On the server, list the real fingerprints:
for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done -
The
SHA256:...values must match. If they do not, stop. - Paste the
|1|...lines ofknown_hosts.txt(not the#comment lines) as the variable's value, then delete the file.
The same value works for every project deploying to the same server. If the host keys ever change (a reinstall), deploys fail with Host key verification failed until the variable is updated: that failure is the protection working.
3. The Standard .gitlab-ci.yml Deploy Job
Every deploy job connects the same way; only the command it runs on the server differs with the project's pattern (Deployment Patterns):
- Docker Compose (most services):
cd /opt/<project> && git pull origin main && docker compose up -d --build. - Static site or systemd service:
cd /opt/<project> && deploy/deploy.sh $CI_COMMIT_SHA, the project's own script, which an admin also runs by hand (the deploy script).
The template below shows the script variant; for a Docker Compose project, replace the last line of script: with the Compose command.
deploy_main:
stage: deploy
image: alpine:3.22 # pinned, never :latest
needs: [lint, test] # deploy only what passed
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"
# One deploy at a time. A resource group does not keep them in order, so the
# script also refuses to go back to an older commit.
resource_group: production
interruptible: false
timeout: 30m
environment:
name: production
url: https://<project-url>
variables:
GIT_STRATEGY: none # the server has its own checkout
DEPLOY_DIR: /opt/<project>
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | base64 -d | ssh-add -
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
- |
if [ -n "${SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
else
echo "SSH_KNOWN_HOSTS is not set; trusting the host key the server presents" >&2
ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
fi
script:
- ssh "app-runner@$SERVER_IP" "cd $DEPLOY_DIR && deploy/deploy.sh $CI_COMMIT_SHA"
# Docker Compose project instead:
# - ssh "app-runner@$SERVER_IP" "cd $DEPLOY_DIR && git pull origin main && docker compose up -d --build"
Notes:
- Use
rules:, notonly:/except:(the older syntax, which cannot combine conditions). - Also turn on Settings ➡️ CI/CD ➡️ General pipelines ➡️ Prevent outdated deployment jobs.
- With merge requests, every merge to
maindeploys. Docs-only changes can be skipped withchanges:on the rule.
Configuration Pitfalls
- Never name a job after a global keyword. At the top level of
.gitlab-ci.yml,image,services,cache,variables,default,stages,include,workflow,before_scriptandafter_scriptare keywords. A job calledimage:makes GitLab reject the whole file (image name should be a string), so no job runs at all. Name itdocker_buildor similar. Check new files in Build ➡️ Pipeline editor ➡️ Validate. - Slim images lack tools.
python:*-slimhas nogit; install what the tests need in the job (apt-get install -y --no-install-recommends git). - Hidden templates. Share the SSH setup between deploy jobs with a hidden job (
.deploy:) andextends:, or reuse one step with!reference [.job, before_script].
4. Troubleshooting Silent Pipeline Failures
SSH pipelines can be notoriously difficult to debug because they often swallow standard error outputs. If your pipeline returns ERROR: Job failed: exit code 1 but provides no text context, check the following:
The "Invisible Shell" Trap
If ssh connects but instantly dies without running your script, the app-runner account might be missing a valid login shell. Debian sometimes defaults service accounts to /bin/false.
The Fix: Assign a proper bash shell to the account from your admin user:
sudo usermod -s /bin/bash app-runner
Absolute Paths for Package Managers
Automated SSH sessions often fail to load user-specific $PATH variables (like ~/.local/bin). On this host Poetry is installed with pipx and linked at /usr/local/bin/poetry, and Node and npm are in /usr/bin, so both are on the default PATH. For anything installed per user, use the absolute path to the executable:
# BAD:
git pull origin main && poetry install
# GOOD:
git pull origin main && ~/.local/bin/poetry install
The "Nuclear" Debug Method
To force the pipeline to print exactly why a command is failing, modify your script block to redirect all output to a server-side log, then read it back:
script:
- >
ssh app-runner@$SERVER_IP
"bash -l -c 'set -x && cd /opt/<project-name> && git pull origin main' > /tmp/ci-deploy.log 2>&1" || true
- ssh app-runner@$SERVER_IP "cat /tmp/ci-deploy.log"
- ssh app-runner@$SERVER_IP "grep -i 'error\|failed\|fatal' /tmp/ci-deploy.log && exit 1 || exit 0"