CI/CD Tutorial: Build a Safe Development + Production Workflow with GitHub Actions, Docker, and Modern IDEsBuild a safe CI/CD workflow with GitHub, GitHub Actions, and Docker: develop new features on isolated branches, auto-deploy to a dev environment, and deploy to production only through tagged, approved releases—without risking the live environment.

Table of Contents

Introduction: What You’ll Build (and Why It Matters)

This tutorial shows you exactly how to set up a project that supports safe feature development without risking your live environment. You will create:

  • A production environment that only updates from approved releases.
  • A development environment for rapid iteration and integration testing.
  • A branching model that keeps unfinished work away from production.
  • A CI pipeline that runs linting, tests, and builds on every pull request.
  • A CD pipeline that deploys automatically to dev and deploys to prod only when you tag a release.
  • A Docker-based workflow so “works on my machine” stops being a problem.

You will implement everything using GitHub, GitHub Actions, Docker, and an IDE like Visual Studio Code, Cursor, or Codex-based tooling. The example app is intentionally small but the workflow scales to real systems.

Prerequisites

Tools You Must Install

  • Git (latest)
  • Docker Desktop (or Docker Engine on Linux)
  • Node.js 20+ (for the demo app) and npm
  • An IDE: VS Code (recommended), Cursor, or similar
  • A GitHub account

Knowledge You Should Have

  • Basic Git commands: clone, commit, push, pull, branch
  • Basic understanding of pull requests (PRs)
  • Basic command line usage

What You’ll Deploy To

To keep this tutorial universally usable, you will deploy using Docker in a way that can run on any server. You will also configure GitHub Environments (Dev/Prod) and show how to safely gate production deployment.

Note: If you already have a hosting platform (AWS/GCP/Azure/Fly.io/Render/VPS), you can still use the exact same CI steps. Only the final “deploy” command changes.


Step 1: Create the Repository and Define the Branch Strategy

Do this first because your branch model determines how code flows into dev and production. If you skip this, you’ll end up deploying from random branches and inevitably push unstable code live.

1.1 Create the GitHub repository

  1. Go to GitHub → New repository.
  2. Name it cicd-safe-workflow.
  3. Initialize with a README.md.
  4. Create the repo.

1.2 Choose a safe branch model

Use:

  • main = production
  • develop = development integration branch
  • feature/* = new work branches

Why this works: Feature branches isolate incomplete work. develop collects changes for dev deployments. main stays stable and deploys only when you intentionally release.

1.3 Create the develop branch locally and push it

git clone https://github.com/<YOUR_ORG_OR_USER>/cicd-safe-workflow.git
cd cicd-safe-workflow

git checkout -b develop
git push -u origin develop

Expected result

  • You have two branches on GitHub: main and develop.

Screenshot description

GitHub repository page → branch dropdown showing main and develop.


Step 2: Add a Small App + Tests (So CI/CD Has Something Real to Run)

Do this because CI/CD pipelines are only meaningful if they run automated checks. You need at least a server endpoint, a test, and a build step.

2.1 Create a minimal Node.js API

In the repository root, create this structure:

cicd-safe-workflow/
  src/
    server.js
  test/
    server.test.js
  package.json

2.2 Initialize npm and install dependencies

npm init -y
npm install express
npm install --save-dev jest supertest

2.3 Add the server code

Create src/server.js:

const express = require("express");

const app = express();

app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});

app.get("/", (req, res) => {
  res.send("Hello from CI/CD-safe workflow!");
});

// Export for tests
module.exports = app;

// Run server only when executed directly
if (require.main === module) {
  const port = process.env.PORT || 3000;
  app.listen(port, () => console.log(`Server running on port ${port}`));
}

2.4 Add a test

Create test/server.test.js:

const request = require("supertest");
const app = require("../src/server");

describe("GET /health", () => {
  it("should return ok", async () => {
    const res = await request(app).get("/health");
    expect(res.statusCode).toBe(200);
    expect(res.body).toEqual({ status: "ok" });
  });
});

2.5 Configure scripts in package.json

Edit package.json:

{
  "name": "cicd-safe-workflow",
  "version": "1.0.0",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "test": "jest --runInBand",
    "lint": "node -e \"console.log('Add ESLint later')\""
  }
}

2.6 Run locally to confirm it works

npm test
npm run start

Expected result

  • npm test passes.
  • Visiting http://localhost:3000/health returns:
{ "status": "ok" }

Commit the changes to develop

git add .
git commit -m "Add minimal API and tests"
git push

Step 3: Dockerize the App (So Dev/CI/Prod Behave the Same)

Do this because Docker makes your build and runtime reproducible. Your CI runner will build the same container image you deploy later.

3.1 Create a Dockerfile

Create Dockerfile in the repo root:

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY src ./src

ENV PORT=3000
EXPOSE 3000

CMD ["node", "src/server.js"]

Why this Dockerfile is structured this way

  • COPY package*.json then npm ci enables Docker layer caching, so rebuilds are fast.
  • --only=production keeps the image smaller and reduces attack surface.

3.2 Add a .dockerignore

Create .dockerignore:

node_modules
npm-debug.log
.git
.github

3.3 Build and run the container locally

docker build -t cicd-safe-workflow:dev .
docker run --rm -p 3000:3000 cicd-safe-workflow:dev

Expected result

  • You can open http://localhost:3000/ and see:
Hello from CI/CD-safe workflow!

Commit Docker changes

git add .
git commit -m "Dockerize the app"
git push

Step 4: Add GitHub Actions CI (Run Tests on Every PR)

Do this because CI prevents broken code from reaching shared branches. The goal is: no PR merges unless tests pass.

4.1 Create the CI workflow file

Create .github/workflows/ci.yml:

name: CI

on:
  pull_request:
    branches: ["develop", "main"]
  push:
    branches: ["develop"]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Why run CI on PRs and on pushes to develop

  • PR checks protect merges into develop and main.
  • Push checks on develop ensure the integration branch stays deployable.

4.2 Push and verify

git add .
git commit -m "Add GitHub Actions CI"
git push

Expected result

  • GitHub → Actions shows a workflow run named CI.
  • The job test ends with a green check.

Screenshot description

GitHub Actions tab → CI workflow run → “Run tests” step showing PASS test/server.test.js.


Step 5: Add CD to Development (Auto-Deploy from develop)

Do this because dev deployments should be automatic. As soon as code merges to develop, you want a live dev environment for QA and stakeholder review.

This section uses a generic SSH + Docker deployment pattern (works on any Linux server). You can adapt it to your platform later.

5.1 Prepare a dev server (one-time)

You need a Linux host reachable by SSH (VM/VPS). Install Docker on it and ensure you can SSH in.

WARNING (potentially destructive): The deployment script below stops and removes an existing container with the same name. Use unique names per environment to avoid stopping the wrong service.

5.2 Create GitHub Environment: development

  1. GitHub repo → Settings → Environments → New environment.
  2. Name it development.
  3. Add environment secrets (see next step).

5.3 Add secrets for dev deployment

Add these secrets in the development environment:

  • DEV_HOST = your server IP or hostname
  • DEV_USER = SSH username
  • DEV_SSH_KEY = private key (ed25519 recommended) with access to the server
  • DEV_PORT = SSH port (usually 22)

Why environment-level secrets: They are scoped. Dev credentials cannot accidentally deploy production.

5.4 Add a CD workflow for dev

Create .github/workflows/cd-dev.yml:

name: CD (Dev)

on:
  push:
    branches: ["develop"]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: development

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build Docker image
        run: |
          docker build -t cicd-safe-workflow:dev-${{ github.sha }} .

      - name: Save image to tar
        run: |
          docker save cicd-safe-workflow:dev-${{ github.sha }} -o image.tar

      - name: Copy image to server
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEV_HOST }}
          username: ${{ secrets.DEV_USER }}
          port: ${{ secrets.DEV_PORT }}
          key: ${{ secrets.DEV_SSH_KEY }}
          source: "image.tar"
          target: "/tmp"

      - name: Load and run on server
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.DEV_HOST }}
          username: ${{ secrets.DEV_USER }}
          port: ${{ secrets.DEV_PORT }}
          key: ${{ secrets.DEV_SSH_KEY }}
          script: |
            set -e
            docker load -i /tmp/image.tar

            # Stop previous container (safe if not running)
            docker rm -f cicd-safe-workflow-dev || true

            docker run -d --restart=always \
              --name cicd-safe-workflow-dev \
              -p 3001:3000 \
              cicd-safe-workflow:dev-${{ github.sha }}

            docker ps --filter "name=cicd-safe-workflow-dev"

Why this approach works

  • Each deploy uses an immutable image tag (dev-<sha>), so you can trace exactly what is running.
  • Dev runs on port 3001 so it never collides with production (you’ll run prod separately).

Expected result

  • A GitHub Actions workflow named CD (Dev) runs on every push to develop.
  • On the server, docker ps shows cicd-safe-workflow-dev.
  • Dev endpoint works at: http://<DEV_HOST>:3001/health returning {"status":"ok"}.

Commit and push

git add .
git commit -m "Add dev CD workflow"
git push

Step 6: Protect main and develop With Branch Rules (Prevent Unsafe Merges)

Do this because CI/CD is not just automation—it’s policy enforcement. Without branch protection, anyone can push broken code directly to main.

6.1 Add protection rules for main

  1. GitHub repo → Settings → Branches → Add branch protection rule.
  2. Branch name pattern: main
  3. Enable:
    • Require a pull request before merging
    • Require status checks to pass → select CI workflow
    • Require branches to be up to date before merging
    • Restrict who can push to matching branches (optional but recommended)

6.2 Add protection rules for develop

Repeat the same steps for develop. This ensures feature branches cannot pollute integration without tests.

Expected result

  • Direct pushes to main are blocked.
  • Merges require passing CI checks.

Screenshot description

Branch protection settings showing “Require status checks to pass before merging” enabled and CI selected.


Step 7: Implement Feature Branch Workflow (Build Safely Without Breaking Live)

Do this because the core promise is: build new features without harming production. The workflow must be easy enough that everyone uses it.

7.1 Create a feature branch

git checkout develop
git pull

git checkout -b feature/add-version-endpoint

7.2 Implement a small change

Edit src/server.js and add:

app.get("/version", (req, res) => {
  res.json({ version: process.env.APP_VERSION || "dev" });
});

7.3 Add a test for it

Add to test/server.test.js:

describe("GET /version", () => {
  it("should return version", async () => {
    const res = await request(app).get("/version");
    expect(res.statusCode).toBe(200);
    expect(res.body).toHaveProperty("version");
  });
});

7.4 Push and open a PR into develop

git add .
git commit -m "Add /version endpoint"
git push -u origin feature/add-version-endpoint

Now create a PR: feature/add-version-endpointdevelop.

Why this is necessary

  • The PR becomes the safety gate: CI runs, reviewers comment, and you get an audit trail.
  • Only after merge does dev auto-deploy, giving you a real environment to validate.

Expected result

  • CI runs on the PR and turns green.
  • After merging into develop, the CD (Dev) workflow deploys automatically.
  • Dev server returns /version JSON.

Step 8: Add Production Deployment (Only from Tagged Releases)

Do this because production must be intentional. A common mistake is auto-deploying production from every merge—this is how unfinished or partially tested work goes live.

8.1 Create GitHub Environment: production (with approvals)

  1. GitHub repo → Settings → Environments → New environment → production
  2. Enable Required reviewers (choose senior engineers / admins)
  3. Add secrets:
    • PROD_HOST
    • PROD_USER
    • PROD_SSH_KEY
    • PROD_PORT

Why approvals matter: It enforces a human checkpoint, even if automation is perfect.

8.2 Add the production CD workflow

Create .github/workflows/cd-prod.yml:

name: CD (Prod)

on:
  push:
    tags:
      - "v*.*.*"

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Build Docker image
        run: |
          docker build -t cicd-safe-workflow:prod-${{ github.ref_name }} .

      - name: Save image to tar
        run: |
          docker save cicd-safe-workflow:prod-${{ github.ref_name }} -o image.tar

      - name: Copy image to server
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          port: ${{ secrets.PROD_PORT }}
          key: ${{ secrets.PROD_SSH_KEY }}
          source: "image.tar"
          target: "/tmp"

      - name: Load and run on server
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          port: ${{ secrets.PROD_PORT }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            set -e
            docker load -i /tmp/image.tar

            # WARNING: this will stop/remove the current prod container
            docker rm -f cicd-safe-workflow-prod || true

            docker run -d --restart=always \
              --name cicd-safe-workflow-prod \
              -e APP_VERSION=${{ github.ref_name }} \
              -p 3000:3000 \
              cicd-safe-workflow:prod-${{ github.ref_name }}

            docker ps --filter "name=cicd-safe-workflow-prod"

Expected result

  • Nothing deploys to production on normal merges.
  • Production deploys only when you push a version tag like v1.0.0.
  • Production endpoint returns /version with the tag value.

Step 9: Release Process (Merge to Main, Tag, Deploy)

Do this so releases are predictable and reversible. The idea is: stabilize in develop, then promote to main, then deploy production via tag.

9.1 Open a PR from developmain

  1. On GitHub, create PR: base main ← compare develop
  2. Wait for CI to pass
  3. Get reviews
  4. Merge

9.2 Create and push a release tag

git checkout main
git pull

git tag v1.0.0
git push origin v1.0.0

Expected result

  • GitHub Actions triggers CD (Prod).
  • If you enabled production approvals, the workflow pauses until approved.
  • After deployment, production server responds on port 3000.

Screenshot description

GitHub Actions → CD (Prod) run → “Waiting for approval” banner under the production environment gate.


Step 10: Configure Your IDE for Faster, Safer Delivery (VS Code, Cursor, Codex)

Do this because modern IDEs reduce mistakes and speed up iteration, especially when paired with containerized dev environments.

10.1 VS Code: add recommended extensions

Create .vscode/extensions.json:

{
  "recommendations": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "ms-azuretools.vscode-docker",
    "github.vscode-github-actions"
  ]
}

10.2 Use Cursor/Codex-style assistants responsibly

Do this:

  • Ask for small, reviewable diffs (one feature or one refactor at a time).
  • Tell the assistant your branch target (feature branch, not main).
  • Require the assistant to add/adjust tests alongside code changes.

Do not do this:

  • Do not paste production secrets into prompts.
  • Do not accept large code dumps without running tests and reading diffs.

Expected result

  • Your IDE consistently nudges contributors toward formatting, linting, Docker, and GitHub Actions visibility.

Troubleshooting: Common Issues and Fixes

Problem: CI fails on npm ci

Why it happens: npm ci requires a lock file. If you never committed package-lock.json, installs may fail.

Fix:

npm install
git add package-lock.json
git commit -m "Add lockfile"
git push

Problem: CD fails with SSH permission denied

Why it happens: Wrong user, wrong key format, missing authorized key on server.

Fix:

  • Ensure your server has your public key in ~/.ssh/authorized_keys.
  • Ensure the GitHub secret contains the private key and includes the header/footer lines.
  • Test locally:
ssh -i path/to/key <DEV_USER>@<DEV_HOST> -p 22

Problem: Container runs but the port doesn’t respond

Why it happens: Firewall blocks the port or you mapped it wrong.

Fix:

  • Verify mapping: -p 3001:3000 for dev, -p 3000:3000 for prod.
  • On server:
docker logs cicd-safe-workflow-dev --tail=100
curl -i http://localhost:3000/health

Problem: Production deploy overwrote the wrong service

Why it happens: Reusing container names or ports across environments.

Fix: Use unique container names and ports per environment (as shown). Consider a reverse proxy (Nginx/Caddy/Traefik) and distinct hostnames (dev.example.com, api.example.com).


Testing: Verify the Whole Pipeline End-to-End

1) Verify CI on a PR

  1. Create a new feature branch.
  2. Make a change and push.
  3. Open a PR to develop.
  4. Confirm GitHub shows required checks passing.

Expected output

  • PR shows a green check and “All checks have passed”.

2) Verify dev auto-deploy

  1. Merge the PR into develop.
  2. Wait for CD (Dev) workflow to finish.
  3. Hit the dev endpoint:
curl http://<DEV_HOST>:3001/health
curl http://<DEV_HOST>:3001/version

Expected output

{"status":"ok"}

3) Verify production gated deploy

  1. Open PR: developmain, merge after checks.
  2. Tag release:
git checkout main
git pull
git tag v1.0.0
git push origin v1.0.0
  1. Approve deployment if required.
  2. Validate:
curl http://<PROD_HOST>:3000/health
curl http://<PROD_HOST>:3000/version

Expected output

  • /version returns the release tag:
{"version":"v1.0.0"}

Next Steps: Make This Production-Grade

1) Add linting and formatting checks

Do this to catch issues earlier than tests. Add ESLint + Prettier and run them in CI.

2) Add a staging environment

Do this if you want a “pre-prod” environment that mirrors production more closely than dev.

3) Use a container registry instead of SCP

Do this for scalability. Push images to GHCR (GitHub Container Registry) and pull from servers during deploy. This avoids copying large tar files.

4) Add database migrations

Do this if your app uses a DB. Add a migration step that runs safely on deploy (and plan rollbacks).

5) Add rollbacks

Do this to reduce downtime. Keep the last known-good image and switch back if health checks fail.


Conclusion

You now have a complete CI/CD workflow that supports safe feature development: feature branches isolate work, CI blocks broken merges, dev auto-deploys from develop for rapid validation, and production only deploys when you tag a release—optionally gated by approvals. Use this exact structure for larger projects, then extend it with staging, registries, and rollback strategies as your system grows.

If you want to tailor this to a specific host (AWS/GCP/Azure, Fly.io, Render, Kubernetes, or GitHub Pages for frontends), keep the CI logic and swap only the deploy step.

Leave a Reply