CI/CD with GitHub Actions and GHCR
A pipeline in three jobs, each depending on the previous one:
- Check on every push and pull request: install with a frozen lockfile, format check, lint, type check, tests, build, and
bash -non shell scripts. - Image, only on
main: build the Docker image and push it toghcr.io/<owner>/<repo>, tagged with the commit SHA andlatest.docker/build-push-actionwithplatforms: linux/amd64,linux/arm64builds for both Intel VMs and Arm servers. - Deploy: SSH to the server and switch it to the new tag.
GHCR
- The workflow logs in with the built-in
GITHUB_TOKEN; the job needspermissions: packages: write. - An image built from a public repository is public too, so the server can
docker pullwithout credentials. For a private one, make the package public ordocker login ghcr.ioon the server. - Tagging by commit SHA makes rollbacks trivial: deploy the previous tag.
Deploying over SSH safely
- Generate a key used only by CI, add its public half to the server's
authorized_keys, and store the private half as a repository or environment secret. Delete the local copy after. - Store the server's host key (
ssh-keyscan <host>) as a secret too and write it toknown_hosts, so CI verifies it is talking to your server. - Gate the job on a variable (
if: vars.DEPLOY_ENABLED == 'true') so the pipeline stays green before the server exists. - The remote command should be a script in the repository (
git pull && bash infra/deploy.sh <image>), not logic hidden in the workflow.
Secrets vs variables
Secrets are encrypted and masked in logs (keys, hosts, tokens). Variables are plain configuration (feature flags such as DEPLOY_ENABLED). Both live under the repository's Settings, or per environment such as production.