A manual deployment always looks the same: somebody logs on to the server, pulls the code, runs the build, restarts the application. It works - as long as that somebody remembers every step, performs them in the same order, and does not deploy at five o'clock on a Friday.
A legionary forge did not rely on the smith's memory. It had a settled order: the same steel, the same temperature, the same tempering, the same mark on every blade. That is why a sword forged in Gaul fitted a scabbard made in Rome. CI/CD is that order written down in a file.
CI/CD stands for Continuous Integration / Continuous Deployment. Not "Container", not "Component", not "Code": both words refer to the process, not to what is being processed.
Each half solves a different problem. Continuous Integration is the automatic building and testing of code after every change - so that a fault comes to light within minutes, rather than a week later when nobody remembers whose change introduced it. Continuous Deployment is the automatic release of a change that has passed verification - so that the road from finished code to production does not depend on whether somebody has the time.
There are many tools; we shall stay with one. GitHub Actions automate build, test and deployment after each push and pull request (PR) - that is their whole role.
Three misunderstandings are worth clearing away at once. They are not used only for managing issues - that is a separate GitHub feature. They are not available only for private repositories; public ones use them free and without a minute limit. And they do not replace Docker - on the contrary, it is in a workflow step that you call
docker build and docker push, so the one makes use of the other.The configuration is a YAML file in the
.github/workflows/ directory:1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5 push:
6 branches:
7 - mainThe fragment above is the trigger - the statement of the event that starts the workflow. It is written in four nestings, always in this order:
opens the events section, on:
names the particular event, push:
narrows it to chosen branches, and branches:
is an item in the list of those branches.- main
In YAML the indentation carries meaning - each further level moves two spaces to the right, and that is what builds the structure. The dash before
main marks a list item, so further branches are added on the lines below.There is more than one event.
pull_request: will run the checks on every PR - and that is usually more valuable than push, because it catches a fault before the change reaches the main branch.The real work happens in a job, and its steps have a settled order:
1jobs:
2 build:
3 runs-on: ubuntu-latest
4 steps:
5 - uses: actions/checkout@v4
6
7 - uses: actions/setup-node@v4
8 with:
9 node-version: 20
10
11 - run: npm ci
12 - run: npm test
13 - run: npm run build
14 - run: docker push registry.imperium.rome/legion-api:latestThe order of the stages is not a matter of taste: install dependencies (
) → run linter and tests (npm ci
) → build the application (npm test
) → deploy to production (npm run build
).docker push
One principle governs it: the cheaper the stage, the earlier it goes. A linter finishes in seconds, tests in minutes, an image build in a quarter of an hour. Were the build to run before the tests, every typo would cost the full build time - and end in failure regardless.
The two steps at the start are preparation rather than pipeline stages.
actions/checkout fetches the repository's code onto the machine the workflow runs on - without it the directory is empty. actions/setup-node installs the stated version of Node.js and thereby settles the "it works on my machine" argument: the version is written in the file, so every run gets the same one.Note
npm ci rather than npm install. The first installs exactly what is recorded in package-lock.json and stops if that disagrees with package.json. The second may quietly raise a dependency's version - and then CI is testing something other than what you have locally.The steps run one after another, and the first failure stops the rest. This is the default behaviour and the desirable one: if the tests do not pass, building an image and pushing it to a registry would merely waste time, and at worst deploy a broken version.
Hence a practical division. Run the checks - install, linter, tests - on every push and every PR, on every branch. Restrict deployment to the main branch, through the
branches: - main of the earlier section. Work on a feature is then verified from its first commit, while nothing reaches production until the change is merged.The forge tempers every blade alike, @name:
.github/workflows/ and is a YAML file whose structure is built by indentation,on: → push: → branches: → - main; pull_request: checks a change before it is merged,npm ci → npm test → npm run build → docker push, cheapest first,actions/checkout fetches the code, actions/setup-node fixes a Node.js version identical for every run,npm ci installs exactly what is in package-lock.json; npm install may change versions quietly,We shall return to CI/CD in the infrastructure module, with builds across several versions at once, artifacts and dependency caching. For now remember: a pipeline is not there to deploy faster. It is there to deploy the same way - and the speed follows.