Most teams write their build and test steps straight into GitHub Actions YAML. It works, right up until you want to run the same build on your laptop, or someone suggests moving to GitLab and you realise the whole pipeline is welded to one vendor’s config format. My preference is fairly boring: keep the real logic in Docker Compose, and let the CI tool do nothing except call it.
Why I keep build logic out of Actions YAML#
The obvious problem is lock-in. An Actions workflow means nothing to GitLab, Bitbucket or Jenkins, so if your build steps live in the workflow file, changing providers means rewriting all of them. Nobody ever budgets time for that.
The quieter problem is that you can’t run the pipeline locally. When the only way to test a change to the build is pushing a commit and watching the Actions tab, you end up with a git history full of “fix ci”, “fix ci again”, “please”. A Compose file runs the same on my laptop as on the runner, which also kills most “works on my machine” arguments before they start.
And the YAML grows. Twenty tidy lines turn into three hundred once caching tricks and conditionals show up, and now every developer has to learn a CI dialect on top of Git and everything else. A script should do one thing; a big workflow file ends up doing all of them at once.
Compose splits the responsibilities cleanly: the Compose file owns building and testing, and the CI platform is just the thing that runs it on every push.
The setup#
- Start with a
Dockerfile, so building the image has exactly one home:
# Use an official Node.js runtime as the base image
FROM node:14
# Set the working directory in the container
WORKDIR /app
# Copy the package.json and package-lock.json files to the container
COPY package*.json ./
# Install the dependencies
RUN npm install
# Copy the rest of the application code to the container
COPY . .
# Expose the port the app runs on
EXPOSE 3000
# Define the command to run the application
CMD ["npm", "start"]Then a
docker-compose.build.yamlthat defines the services needed to build and test:services: app: image: myprivaterepo.com/image:latest build: . volumes: - .:/app command: sh -c "npm install && npm test"Run it locally first. This is the whole point of the exercise:
docker compose -f docker-compose.build.yaml buildWith that working, the GitHub Actions workflow (or its equivalent on any other platform) shrinks down to an orchestrator:
name: CI/CD Pipeline on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - name: Build and Test with Docker Compose run: docker compose -f docker-compose.build.yaml build - name: Push the image referenced in the docker compose file run: docker compose -f docker-compose.build.yaml push
The CI tool now does two things, build and push, and everything worth understanding lives in files that any platform (or any new team member) can run directly.
Pushing the image#
A nice side effect of the Compose file is that pushing is trivial. Docker works out where the image goes from its name: image: myprivaterepo.com/image:latest means it gets pushed to myprivaterepo.com. For a private registry you’ll need an access token — check your registry’s docs for that part.
If you’re on GitHub Actions and happy, none of this is urgent. But the day you switch providers, the migration is copying two commands instead of porting three hundred lines of YAML. And until then, you get to debug your builds locally like a civilised person.

