William

William Β· Talent Sourcing Expert Β· September 16, 2026

How to Set Up a CI/CD Pipeline for a Startup in 6 Steps with GitHub Actions

Set up a CI/CD pipeline for a startup: cover with a GitHub Actions badge and a tilted capture of a deploy.yml workflow file in a code editor

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.

GitHub repository Actions tab showing the Get started with GitHub Actions page, a Search workflows field and six suggested starter workflows (Grunt, Gulp, Webpack, Deno, Publish Node.js Package to GitHub Packages, Publish Node.js Package), each with a Configure button
The Actions tab of a repository before any workflow exists: GitHub suggests templates, with a link to set up a workflow yourself instead.

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 build

Line by line:

  1. on.pull_request.branches: [main] fires the workflow whenever a pull request targets main.
  2. jobs.test is the single job. A workflow can hold several; this one needs only one.
  3. runs-on: ubuntu-latest chooses the operating system of the virtual machine GitHub spins up. Other images exist, but Ubuntu is what most teams use for Actions.
  4. container: image: node:20 runs the job inside a Node.js container, so Node is already installed and you skip installing it through the command line yourself.
  5. actions/checkout@v3 copies your repository into that environment. Nothing else can run before this step.
  6. npm ci does a clean install of the dependencies from the lockfile.
  7. npm test runs whatever test runner the project uses. In a JavaScript project that is often Jest; the workflow does not care which.
  8. npm run build compiles the project. A branch that does not build is as blocked as a branch that fails a test.
VS Code with .github/workflows/test.yml open: name Test Project, trigger on pull_request to branches main, a test job on ubuntu-latest in a node:20 container, and steps actions/checkout@v3, npm ci, npm test, npm run build
The complete test workflow, nineteen lines. The explorer on the left shows where it lives: .github/workflows/test.yml, next to the project's own source.

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.

GitHub Actions run log for a job named test: steps Install dependencies (15s, passed) and Run tests (2s, passed) with Jest output reading Test Suites 1 passed, Tests 2 passed, and a Run build step in progress showing next build output
A pull request run in progress: dependencies installed, two tests passed in under a second, build compiling. The green checks next to each step are what the reviewer looks at first.

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:

  1. SSH_PRIVATE_KEY: the full content of a private key whose public half is installed on the server.
  2. SSH_HOST: the address of the server you deploy to.
  3. 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.

GitHub repository Settings page, Secrets and variables > Actions selected in the left sidebar, showing the Actions secrets / New secret form with Name set to SSH_PRIVATE_KEY, a Secret text area containing a placeholder value, and a green Add secret button
Adding SSH_PRIVATE_KEY as a repository secret. SSH_HOST and SSH_USER go in the same form; none of the three ever appears in the repository or in the logs.

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_rsa on the runner and restricts the file to its owner with chmod 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.

VS Code editor showing deploy.yml: name Deploy Project, trigger on push to main, a deploy job on ubuntu-latest in a node:20 container, steps actions/checkout@v3, npm ci, npm run build, Get SSH Key and set permissions with mkdir and echo of secrets.SSH_PRIVATE_KEY into ~/.ssh/id_rsa, Deploy using SCP with scp to secrets.SSH_USER at secrets.SSH_HOST, and Restart Server over ssh
deploy.yml in the editor. Lines 19 to 26 are the whole delivery half of CI/CD: install the key, upload dist/ with scp, restart over ssh.

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:

  1. Docker running locally. act executes each job in a container, the same way GitHub does.
  2. A .secrets file 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 real scp and 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.

GitHub Actions run log for a job named deploy: Run npm ci (7s, passed), Run npm run build (2s, passed), Get SSH Key and set chmod permissions (passed), Deploy using SCP in progress showing scp to a masked ***@*** host and a known-hosts warning, and pending steps Restart Server, Post Run actions/checkout@v3, Stop containers
The deploy run triggered by the merge: build done, the scp step uploading to a masked ***@*** target, restart still queued. The masked values are the secrets from step 4.

Common mistakes when a startup sets this up

  • Testing the deploy workflow against production. The .secrets file act uses will run the real scp and 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 .secrets file belongs in the repository. GitHub's secrets store exists precisely so the workflow can use credentials the repository never contains.
  • Passing node-version to actions/checkout. That input belongs to the setup-node action, and the checkout action will warn that it is unexpected. With a node:20 container the runtime is already there, so no with block is needed at all.
  • Deploying from every branch. If the deploy trigger is a bare push with no branch filter, every feature branch ships to the server. Restrict it to main.
  • 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.
VS Code explorer with a new file named .secrets being created at the project root, next to .github/workflows containing deploy.yml and test.yml; the editor shows deploy.yml with the SSH key, SCP and restart steps
The .secrets file act reads sits at the project root, beside .github/workflows. It holds test values for SSH_PRIVATE_KEY, SSH_HOST and SSH_USER, and it never gets committed.

Check that the pipeline stuck

6 questions on the brief you just read. Pick one answer per question.

  1. 1. Which event should trigger the workflow that runs the test suite?

  2. 2. Why run the test job inside a node:20 container?

  3. 3. Where should the SSH private key used for deployment be stored?

  4. 4. What does the deploy workflow do after the build finishes?

  5. 5. What does act need in order to run a workflow locally?

  6. 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.

Ready to hire?

Vetted talent ready for US teams. No recruitment fees. Zero risk.

πŸ‡ΊπŸ‡Έ Trusted by companies across the United States