
This CI/CD pipeline tutorial shows you how to set up GitHub Actions so it runs your tests and deploys your app every time you push code. You won't need any DevOps experience. If you can add, commit and push with Git, you can follow every step.
Each section below is one stage of the build, in the order you'll do it. You'll create the workflow file, choose when it runs, add a test job and add a deploy job that waits for the tests. Then you'll store your deploy key safely and watch the pipeline pass a good push and block a bad one. Along the way, you'll see why each step matters, so you can adapt the pipeline to your own project.
- A CI/CD pipeline tests every change you push and deploys the changes that pass.
- GitHub Actions only reads workflow files from .github/workflows in your repository.
- Add a test job, then a deploy job with needs: test so broken code never ships.
- Keep deploy keys in GitHub's repository secrets, never in your YAML file.
- Most first-run failures come from YAML indentation, missing secrets or a wrong branch name.
- 0:00Intro
- 0:28What you'll build and what you need
- 1:24What a CI/CD pipeline actually does
- 2:24See how GitHub Actions runs your pipeline
- 3:10Create the workflow file
- 4:06Define when the pipeline runs
- 5:01Add steps to check out and test
- 5:59Follow what happens on each push
- 6:57Add a deploy job after tests
- 7:52Store deploy keys as secrets
- 8:48Push and watch it turn green
- 9:39Watch the pipeline block a bad push
- 10:27Fix the three most common errors
- 11:33Make your pipeline faster and safer
- 12:21Your pipeline in four steps
- 12:52Let your tests guard every deploy
Gather what you need before you start
Here is the end result. You type git push and get on with your day. A few minutes later your tests have run, and if they passed, the new version is live. You don't do anything by hand. To get there, you need a Git repository, a small app with at least one test, and a GitHub account where that repository is pushed. You should also be comfortable with git add, commit and push.
Beginners often overlook the role of tests. A pipeline doesn't know on its own that your app is broken. It only runs your tests, so it can only catch what those tests check. With no tests, nothing gets caught. Even one or two simple tests make the pipeline worth having.
For your first attempt, use a small side project rather than your team's main app. Mistakes then cost you nothing and are easy to undo.
- Your project is in a Git repository pushed to GitHub
- The app has at least one test you can run locally
- You can add, commit and push without looking up the commands
- You're practising on a small project, not a production app
Understand what CI and CD each do
CI/CD sounds more complicated than it is. It's two ideas joined into one automatic loop. CI means continuous integration: every change you push is built and tested automatically. CD means continuous delivery or continuous deployment: changes that pass those checks get shipped without anyone doing it by hand.
The loop is simple. You push a commit, the pipeline runs your tests, and if everything passes, it deploys. You see the result, then push your next change. It repeats every time, so there's nothing to remember.
The value of CI is timing. A bug found minutes after you wrote it is easy to fix, because the code is still fresh in your mind. A bug found weeks later is much harder to track down. CD makes releases small and routine instead of big, stressful events, so when something goes wrong there's less to dig through.
Learn how GitHub Actions runs a workflow
Before building anything, it helps to know the parts. In GitHub Actions, the recipe for your pipeline is called a workflow. A workflow contains jobs, and each job contains steps. GitHub runs the jobs on a machine called a runner.
The path looks like this. You push to your repository, and the push triggers the workflow file. The workflow hands its jobs to a runner. The runner reports pass or fail back to GitHub, and if everything passes, it deploys to your server.
The runner is not your laptop, and this catches a lot of people out. It starts empty on every run. Nothing you installed locally is there. That's why your workflow has to check out the code and install dependencies itself. Spell out every step.
- Workflow: the whole pipeline, defined in one YAML file
- Job: a group of steps that runs on one runner
- Step: a single action or command
- Runner: a clean machine GitHub starts for each run
Create the workflow file in .github/workflows
GitHub only reads workflow files from a folder called .github/workflows at the root of your repository. If you put the file anywhere else, GitHub ignores it and shows no error. Your pipeline simply never appears.
Open a terminal in your project's root folder. Create the .github/workflows folder, then create a file inside it called ci.yml. You can use any name, as long as it ends in .yml or .yaml.
Every YAML file in that folder becomes its own separate workflow. You only need one for now, and you can add more later.
- .github starts with a dot
- workflows is spelled correctly, with an s at the end
- The folder is at the repository root, not inside a subfolder
- The file name ends in .yml or .yaml
# run from your repo's root folder
mkdir -p .github/workflows
touch .github/workflows/ci.yml
git add .github/workflows/ci.yml
Set the trigger to run on pushes to main
Now the file needs to know when to start. That's the trigger. At the top of the file, give the workflow a name, such as CI, so it has a readable label. Then add the on key, which answers one question: when should this pipeline start? Under it, add push, and under that, branches with main in square brackets.
YAML depends on exact spacing, so indent with spaces, never tabs. Then check what your default branch is really called, because older repositories often use master. If the file says main but your branch is master, the pipeline never runs, and nothing tells you why.
We trigger on main because main is the branch that reaches users, so it must always work. Later you can add pull requests as a trigger too.
- Indentation uses spaces only
- The branch name matches your real default branch exactly
name: CI
on:
push:
branches: [main]
Add a test job that checks out and runs your tests
The pipeline now knows when to run, but not what to do. Under a top-level jobs key, add a job called test. runs-on picks the machine, and ubuntu-latest is a common choice. The checkout step copies your repository onto the runner. Then npm ci installs dependencies and npm test runs your tests. This example uses a Node app.
The steps run from top to bottom, and they form a chain: a step only runs if every step before it succeeded. On each push, GitHub starts a brand-new machine, so a test can't pass because of something left over from an earlier run. Checkout pulls down the exact commit you pushed, not your local files. Dependencies install from your lock file. If it's missing or out of date, the run breaks here, just as a teammate's fresh setup would. Finally, your tests run. Green means the job continues. Red means it stops.
If you don't use Node, swap in your own stack's commands. A Python app, for example, would install with pip and test with pytest. The structure stays the same: get the code, install, test.
- Checkout is the first step in the job
- The install command matches your stack
- The test command is the one you run locally
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
Add a deploy job that waits for the tests
Testing is half the pipeline. The other half is a deploy job that ships your app, but only after the test job has passed. Add it under jobs, at the same indentation as your test job.
The key line is needs: test. It tells GitHub that deploy depends on test, so deploy waits for test to finish and is skipped if test fails. This matters because jobs run in parallel by default. Without needs, deploy could start while tests are still running and ship broken code anyway. The if line makes sure you only deploy from main.
The last step runs your deploy command, which in this example is a script called deploy.sh. What you put here depends on where your app is hosted. Many hosting platforms provide a command-line tool or a ready-made action you can use instead.
- The deploy job sits at the same level as the test job
- needs: test is present
- The deploy command matches your host
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
Store deploy keys as repository secrets
Your deploy step almost always needs a password or API key to reach your server. This is the most important safety rule in the whole setup: credentials belong in GitHub's encrypted secrets store, never in your workflow file or anywhere else in the repository. Anyone with access to the repo can read committed files, and deleting a key later doesn't remove it from the Git history.
To add a secret, open your repository on GitHub and go to Settings, then Secrets and variables, then Actions. Click New repository secret, give it a clear name such as DEPLOY_TOKEN, and paste in the value. In your deploy step, reference it as ${{ secrets.DEPLOY_TOKEN }}, usually inside an env value. GitHub fills it in during the run and hides it in the logs.
- No keys or passwords appear anywhere in your YAML
- The secret name in the workflow matches the stored name exactly
Push, check the Actions tab and test a failure
Commit your workflow file, with a small change if you like, and push it to main. On GitHub, click the Actions tab near the top of your repository. Your run appears with a yellow in-progress icon. Click into it to watch test and then deploy run, and click any step to read its full log. A green tick next to every step in both jobs means the pipeline works. Open your live app to confirm the change is there.
Next, prove it can fail. Break a test on purpose and push again. The push triggers the workflow, checkout and install succeed, and the test step turns red. Because deploy needs test, deploy is skipped and your users never see the bug. Open the red step and look for the first error message, not the last. It usually shows the real cause. Fix the code, push again, and a new run starts automatically.
- Every step in test and deploy shows a green tick
- Your change is visible in the live app
- A deliberately broken test turns the run red
- Deploy is skipped when the test job fails
Fix common errors and make the pipeline faster
When your app works locally but the pipeline fails, the cause is often the workflow file or the repository settings. Three problems account for most first-run failures. YAML indentation: one misplaced space can change the meaning or break the file, so use spaces only and run the file through a YAML validator if unsure. Missing secrets: an authentication error or empty value usually means the secret isn't set or its name differs, and DEPLOY_TOKEN and deploy_token count as different names. Wrong branch: if no run appears at all, compare the trigger branch with your real one. After a fix, push again or click Re-run jobs.
Once you're green, a few habits keep the pipeline trustworthy as the project grows. Turn on branch protection for main and require the test check to pass, so broken code can't get into main or be deployed. Add pull_request as a second trigger, so reviewers see a result before they approve. And cache dependencies, since installing them is often the slowest step. Faster results mean people actually wait for them.
- Spaces only, with keys at the same level lined up
- Secret names match exactly, including capital letters
- Trigger branch matches your real branch
- Branch protection requires the test job to pass
Key takeaways
- One file, one trigger, one test job and one deploy job make a complete pipeline.
- Your pipeline is only as useful as your tests.
- Always check out the code first, because the runner starts empty.
- needs: test is what stops broken code from deploying.
- Credentials go in repository secrets, never in the workflow file.
- A red run isn't a disaster. It means the pipeline did its job.
Frequently asked questions
Do I need DevOps experience to build a CI/CD pipeline?
No. If you can push code to GitHub and your app has at least one test, you can build a working pipeline with GitHub Actions by following the steps in order.
Why isn't my GitHub Actions workflow running at all?
Check that the file is inside .github/workflows, with the leading dot, and ends in .yml or .yaml. Then check that the branch in your trigger matches your real branch name, for example main versus master.
What is the difference between CI and CD?
Continuous integration builds and tests every change you push. Continuous delivery or deployment ships the changes that pass those checks, without anyone doing it by hand.
How do I stop the deploy job running when tests fail?
Add needs: test to the deploy job. Jobs run in parallel by default, so without this line deploy could run alongside the tests and ship broken code.
Where should I put my API key or deploy password?
Store it as a repository secret under Settings, then Secrets and variables, then Actions. Reference it in your workflow with the secrets syntax. GitHub fills in the value during the run and hides it in the logs.
Can I use this pipeline if my app isn't written in Node?
Yes. Keep the same structure of checkout, install and test, and swap in your stack's commands. A Python app, for example, would install with pip and test with pytest.