
Key takeaways
- βCI/CD on GitHub Actions is two YAML files in .github/workflows/: one triggered by pull requests (test), one triggered by pushes to main (deploy).
- βThe test workflow mirrors what a developer would do by hand: check out the code, npm ci, npm test, npm run build. A red run means the branch does not get merged.
- βCredentials never go in the repository. The SSH private key, the server address and the username live in Settings > Secrets and variables > Actions, and are masked as *** in every log.
- βThe deploy job installs the key on the runner, uploads the build with scp, then runs ssh to install dependencies and restart the Node process with pm2.
- βact runs a workflow locally with Docker and a .secrets file, so the deploy step gets tested against non-production infrastructure before it is ever pushed.
Most early-stage teams deploy by hand. Pull the latest code, check out main, install, build, copy the files to the server with a long scp command, then SSH in to install dependencies and restart the process. It works, right up to the day someone mistypes the command or merges a branch whose tests never ran. Continuous integration and continuous delivery, CI/CD, is nothing more exotic than automating that list: compiling, testing, uploading to your infrastructure, restarting. The obvious gain is time. The bigger one is that a human is no longer in the loop on the one step that can take the whole product down.
This guide builds the two workflows a startup actually needs, on GitHub Actions, with a Node.js project as the running example: one that runs the test suite on every pull request, and one that builds and deploys to a server every time main changes. Both are short YAML files that live in the repository. If you are the founder or CTO rather than the person writing them, treat the six steps as the checklist for what "we have CI/CD" should mean the next time a developer says it. There is a short quiz at the end to check the mechanics stuck.
Step 1: open the Actions tab and pick the two triggers that matter
All you need to start is a GitHub repository. At the top of the repository page sits the Actions tab. Once workflows exist, this is where every run is listed with its logs: while a run is in progress you can watch it move through each step, a failed step turns the run red, and a clean run ends with a green check mark. The rule that makes the whole system worth having is simple: if the run is red because the tests failed, the code is broken and it does not get deployed.
On a fresh repository the tab offers a set of starter templates, grouped by language and purpose. They are a fine way to look at examples, but for a pipeline you will have to maintain it is better to write the two files yourself, because a workflow you wrote is a workflow you can read at 2 a.m. Every workflow is a YAML file in the .github/workflows/ directory, and each one starts with two decisions: a name, and a trigger that tells GitHub when to run it.
GitHub can start a workflow on a long list of events, all documented on the "events that trigger workflows" page of the GitHub docs. For a small team, two of them cover almost everything:
- A pull request opened against
main: run the tests before anyone merges. - A push to
main: build and deploy to production without anyone touching a terminal.
Those two triggers are the two workflows in this guide.

Step 2: write the test workflow that runs on every pull request
Create .github/workflows/test.yml. The trick to writing it is to list what a developer would do by hand to check that a branch is safe to merge, then turn each item into a step.
name: Test Project
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:20
steps:
- uses: actions/checkout@v3
- run: npm ci
- run: npm test
- run: npm run buildLine by line:
on.pull_request.branches: [main]fires the workflow whenever a pull request targetsmain.jobs.testis the single job. A workflow can hold several; this one needs only one.runs-on: ubuntu-latestchooses the operating system of the virtual machine GitHub spins up. Other images exist, but Ubuntu is what most teams use for Actions.container: image: node:20runs the job inside a Node.js container, so Node is already installed and you skip installing it through the command line yourself.actions/checkout@v3copies your repository into that environment. Nothing else can run before this step.npm cidoes a clean install of the dependencies from the lockfile.npm testruns whatever test runner the project uses. In a JavaScript project that is often Jest; the workflow does not care which.npm run buildcompiles the project. A branch that does not build is as blocked as a branch that fails a test.

Step 3: open a pull request and read the run
Commit the file, push a feature branch, and open a pull request against main. The workflow starts on its own: GitHub boots the virtual environment, checks out the branch, installs, tests, builds, and reports back on the pull request. Nobody had to pull the branch to a laptop and run the tests by hand, which is the point. For the reviewer, that removes the "does it even pass?" question and leaves the actual review, the part covered in our guide to reviewing a pull request.
Open the run from the Actions tab and read the log the way you would read a terminal. Each step is collapsible. The dependency install shows the package count and audit result, the test step shows the runner's summary (suites, tests passed, time), and the build step streams the compiler output. A failing step stops the run right there; everything after it stays unexecuted and the pull request shows the failure.
Two things are worth noting for the person managing the team rather than writing the code. First, the log is the record: when a deploy goes wrong later, this is where the evidence is. Second, a red run should be a blocker and not a suggestion. If pull requests with failing checks are being merged anyway, the pipeline is decorative.

Step 4: store the server credentials as Actions secrets
Before automating the deploy, write down what the manual version needs to reach the server: the server address, the username on that server, and an SSH key that is authorized to log in. None of that can be committed to the repository, and the same goes for any other credential a workflow might need, such as a payment provider's API key or an AI provider's key.
GitHub's answer is repository secrets. In the repository, go to Settings, then Secrets and variables, then Actions, and click New repository secret. Create three:
SSH_PRIVATE_KEY: the full content of a private key whose public half is installed on the server.SSH_HOST: the address of the server you deploy to.SSH_USER: the username the deploy logs in as.
Inside a workflow, each one is read with ${{ secrets.NAME }}. GitHub masks the values in every run log, so a shared run URL does not leak the key: you will see *** where the host and user would be.

Step 5: write the deploy workflow that ships on every push to main
Here is the manual deployment the workflow replaces, in the order a developer would type it: git pull, git checkout main, npm install, npm run build, a long scp command to upload the files (easy to get wrong), then ssh into the server to install dependencies and restart the Node process. Slow, error-prone, and completely mechanical, which makes it a perfect candidate for automation.
Create .github/workflows/deploy.yml. It starts exactly like the test workflow, then adds three named steps.
name: Deploy Project
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: node:20
steps:
- uses: actions/checkout@v3
- run: npm ci
- run: npm run build
- name: Get SSH key and set permissions
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa
- name: Deploy using SCP
run: scp -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa -r ./dist/* ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:~/node-app
- name: Restart server
run: ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "cd ~/node-app && npm ci && pm2 restart all"What each named step does:
- Get SSH key and set permissions writes the private key from the secret into
~/.ssh/id_rsaon the runner and restricts the file to its owner withchmod 600, the permission SSH expects on a private key. - Deploy using SCP uploads the compiled
dist/folder to the app directory on the server, using the same command a developer would type by hand, with the user and host pulled from the secrets. - Restart server opens an SSH session, installs the dependencies on the server, and restarts the Node process. The example uses pm2 as the process manager; substitute the command your server uses.
The trigger is the other half of the design: on.push.branches: [main] means the deploy only runs when main changes. Combined with step 2, that gives the sequence every commit follows: tested on the pull request, deployed on the merge.

Step 6: dry-run it locally with act, then merge and watch the deploy
A deploy workflow is the one file you do not want to debug by pushing to main six times. The tool for that is act, which runs GitHub Actions workflows on your own machine. It needs two things:
- Docker running locally. act executes each job in a container, the same way GitHub does.
- A
.secretsfile in the project directory. act reads it in place of the repository secrets. Put test values in it that point at throwaway infrastructure, never your production key or host: you are about to run a realscpand a real restart against whatever is in that file. A staging environment is the natural target. Keep the file out of version control.
Run act from the project root and it walks the workflow step by step, in Docker, with your test secrets. When the local run is clean, commit the workflow and push it. Now merge the feature pull request from step 3. The merge is a push to main, which triggers the deploy workflow; open it in the Actions tab and you can watch the build finish, the SCP upload start with the user and host masked as ***, and the restart step queue up behind it.
From this point on, that is the entire release process: open a pull request, get a green check, merge. Nobody types scp again.

Common mistakes when a startup sets this up
- Testing the deploy workflow against production. The
.secretsfile act uses will run the realscpand the real restart. Point it at a test server, and put only test values in it. - Committing the secrets. Neither the private key nor the
.secretsfile belongs in the repository. GitHub's secrets store exists precisely so the workflow can use credentials the repository never contains. - Passing
node-versiontoactions/checkout. That input belongs to the setup-node action, and the checkout action will warn that it is unexpected. With anode:20container the runtime is already there, so nowithblock is needed at all. - Deploying from every branch. If the deploy trigger is a bare
pushwith no branch filter, every feature branch ships to the server. Restrict it tomain. - Merging on red. The test workflow only protects you if a failing run blocks the merge. Once the team gets used to merging over a red check, the pipeline stops meaning anything.

Check that the pipeline stuck
6 questions on the brief you just read. Pick one answer per question.
1. Which event should trigger the workflow that runs the test suite?
2. Why run the test job inside a node:20 container?
3. Where should the SSH private key used for deployment be stored?
4. What does the deploy workflow do after the build finishes?
5. What does act need in order to run a workflow locally?
6. The test run on a pull request is red. What should happen?
Score: 0 / 6
Two files, one rule: green before merge, merge before deploy
That is the whole pipeline. test.yml runs on every pull request and blocks the merge when something breaks; deploy.yml runs on every push to main and does the upload and restart a developer used to do by hand; the credentials live in Actions secrets instead of the repository; and act lets you rehearse the deploy against a test server before it ever touches production. It fits in an afternoon for a Node project, and the shape is the same for any other stack: only the install, test and build commands change.
If nobody on the team has set one up before, this is a good first task for a contractor with DevOps experience, because the deliverable is two reviewable files. We match US startups with vetted remote DevOps engineers and GitHub Actions specialists; see how it works.
Frequently asked questions
Do we need CI/CD with only two or three developers?
Yes, and arguably more than a large team does, because there is nobody whose job it is to catch a bad deploy. The pipeline described here is two short YAML files: one that runs the tests on every pull request, one that builds and deploys on every push to main. The cost is an afternoon. The payoff is that compiling, testing, uploading and restarting no longer depend on someone remembering the exact command, and that the log of every release is kept for you in the Actions tab.
Why GitHub Actions rather than another CI tool?
Several tools do this job well, and the shape of the pipeline is the same on all of them: a trigger, a job, a list of steps. GitHub Actions has the practical advantage of living where the code already is. The Actions tab is on the repository, the secrets are in the repository settings, and the triggers are pull requests and pushes, events the team already produces every day. There is nothing extra to host or to log in to.
Our app is not Node.js. Does the same structure apply?
The structure does; the commands change. The trigger, the job, the runner and the checkout step are identical. Swap the node:20 container for one that matches your runtime, and replace npm ci, npm test and npm run build with the install, test and build commands of your stack. The deploy half, installing a key from a secret, uploading with scp and restarting over ssh, does not depend on the language at all.
Is it safe to keep an SSH key in GitHub secrets?
That is what the secrets store is for: credentials a workflow needs but the repository must never contain, from SSH keys to payment or AI API keys. The value is entered once through the repository settings and is masked as *** wherever it would appear in a run log. The workflow reads it with ${{ secrets.SSH_PRIVATE_KEY }}, writes it to ~/.ssh on the runner for the duration of the job, and uses it for the scp and ssh steps. What you should not do is put the production key in the .secrets file that act reads locally; that file gets test values only.
Hire vetted talent