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
- Go to GitHub → New repository.
- Name it
cicd-safe-workflow. - Initialize with a
README.md. - Create the repo.
1.2 Choose a safe branch model
Use:
main= productiondevelop= development integration branchfeature/*= 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 developExpected result
- You have two branches on GitHub:
mainanddevelop.
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 supertest2.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 startExpected result
npm testpasses.- Visiting
http://localhost:3000/healthreturns:
{ "status": "ok" }Commit the changes to develop
git add .
git commit -m "Add minimal API and tests"
git pushStep 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*.jsonthennpm cienables Docker layer caching, so rebuilds are fast.--only=productionkeeps 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:devExpected 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 pushStep 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 testWhy run CI on PRs and on pushes to develop
- PR checks protect merges into
developandmain. - Push checks on
developensure the integration branch stays deployable.
4.2 Push and verify
git add .
git commit -m "Add GitHub Actions CI"
git pushExpected 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
- GitHub repo → Settings → Environments → New environment.
- Name it
development. - 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 hostnameDEV_USER= SSH usernameDEV_SSH_KEY= private key (ed25519 recommended) with access to the serverDEV_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
3001so 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 psshowscicd-safe-workflow-dev. - Dev endpoint works at:
http://<DEV_HOST>:3001/healthreturning{"status":"ok"}.
Commit and push
git add .
git commit -m "Add dev CD workflow"
git pushStep 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
- GitHub repo → Settings → Branches → Add branch protection rule.
- Branch name pattern:
main - 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
mainare 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-endpoint7.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-endpointNow create a PR: feature/add-version-endpoint → develop.
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
/versionJSON.
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)
- GitHub repo → Settings → Environments → New environment →
production - Enable Required reviewers (choose senior engineers / admins)
- Add secrets:
PROD_HOSTPROD_USERPROD_SSH_KEYPROD_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
/versionwith 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 develop → main
- On GitHub, create PR: base
main← comparedevelop - Wait for CI to pass
- Get reviews
- Merge
9.2 Create and push a release tag
git checkout main
git pull
git tag v1.0.0
git push origin v1.0.0Expected 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 pushProblem: 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 22Problem: 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:3000for dev,-p 3000:3000for prod. - On server:
docker logs cicd-safe-workflow-dev --tail=100
curl -i http://localhost:3000/healthProblem: 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
- Create a new feature branch.
- Make a change and push.
- Open a PR to
develop. - Confirm GitHub shows required checks passing.
Expected output
- PR shows a green check and “All checks have passed”.
2) Verify dev auto-deploy
- Merge the PR into
develop. - Wait for CD (Dev) workflow to finish.
- Hit the dev endpoint:
curl http://<DEV_HOST>:3001/health
curl http://<DEV_HOST>:3001/versionExpected output
{"status":"ok"}3) Verify production gated deploy
- Open PR:
develop→main, merge after checks. - Tag release:
git checkout main
git pull
git tag v1.0.0
git push origin v1.0.0- Approve deployment if required.
- Validate:
curl http://<PROD_HOST>:3000/health
curl http://<PROD_HOST>:3000/versionExpected output
/versionreturns 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.

