Goal for Today
By the end of class, we want this example project to support Continuous Deployment.
That means:
CI still runs tests automatically
A successful push to the deployment branch triggers deployment
The server updates automatically
The app restarts automatically
The new version is live without us logging in and doing all steps by hand
We are not trying to build a perfect production DevOps pipeline today.
We are building a first working CD pipeline that is:
Understandable
Small
Correct
Easy to adapt to your projects
Important Firewall Note
Our deployment server is:
10.192.145.179
This server is behind a firewall.
That means the original SSH-based CD plan will not work from a normal GitHub-hosted runner, because GitHub cannot directly SSH into the server.
So we will use a different strategy:
A GitHub Actions self-hosted runner will run on 10.192.145.179.
That runner sits inside the firewall, so it can update the local project directly.
This replaces the original idea where “GitHub connects to the Linux server over SSH” .
What CD Means Here
In this class, CD means:
Code is pushed to GitHub
GitHub Actions runs the test pipeline
If the tests pass, GitHub sends the deployment job to the self-hosted runner
The runner updates the deployed copy of the project
It installs dependencies if needed
It builds the frontend
It restarts the backend server with PM2
The updated app is live
For this scaffold, the backend serves the built frontend, so deployment is naturally a single-server deployment.
Part 1: Start from the Test Setup Branch
We are starting with the branch that already has:
Backend tests
Frontend tests
Playwright smoke test
GitHub Actions CI
Create a new working branch for CD:
git checkout testinclass1
git pull
git checkout -b cdsetup
We will do our CD work on:
cd setup
Part 2: Decide the Deployment Strategy
Because the server is behind a firewall, we will not deploy by SSHing from GitHub into the server.
Instead, we will:
Install a self-hosted GitHub Actions runner on 10.192.145.179
Keep one deployed copy of the repo on the server
Let the runner run the deployment script locally
Update the copy with git pull
Rebuild the frontend
Restart the app with PM2
Flow:
Tested code → self-hosted runner → update deployed repo → rebuild → restart
That is exactly the right level for a first CD activity.
Part 3: One-Time Server Preparation
These steps happen once on the deployment server.
1. Choose the Deployment Directory
Pick a permanent location on the server.
For example:
/home/YOUR_USERNAME/sd/Demo-Scadfold-project
Use one location and stick with it.
2. Clone the Repository Onto the Server
On the server:
cd ~/sd
git clone git@github.com:YOUR_USERNAME/YOUR_REPO.git Demo-Scadfold-project
cd Demo-Scadfold-project
git checkout cdsetup
Do not use sudo for this clone unless you have a very specific reason.
The project should normally be owned by your Linux user.
3. Make Sure .env Exists on the Server
Your deployed app still needs the server-side .env file.
For example:
cd ~/sd/Demo-Scadfold-project/server
cp .env.example .env
nano .env
Set the correct values, including your assigned 41xx port.
Example:
PORT=4101
Use your own assigned port.
4. Install Dependencies and Test Manually
From the project root:
cd ~/sd/Demo-Scadfold-project
npm install
Build the frontend:
cd client
npm install
npm run build
Install backend dependencies:
cd ../server
npm install
Start the server manually:
npm start
Now open:
http://10.192.145.179:41xx
Replace 41xx with your assigned port.
If this does not work manually, stop and fix it before automating deployment.
5. Put the App Under PM2
PM2 is already installed on the server.
From the server directory:
cd ~/sd/Demo-Scadfold-project/server
pm2 start index.js --name demo-scaffold
pm2 save
Now check:
pm2 list
Your process should be there.
This gives us a stable target for automated restart.
Part 4: Create a Deployment Script
Create this file in the root of your project:
deploy.sh
Put this in the file:
#!/bin/bash
set -eecho "Starting deployment..."git checkout cdsetup
git pull
echo "Installing root dependencies..."
npm installecho "Building frontend..."
cd client
npm install
npm run build
echo "Installing backend dependencies..."
cd ../server
npm install
echo "Restarting app with PM2..."
pm2 restart demo-scaffold
echo "Deployment complete."
Make it executable:
chmod +x deploy.sh
Test it manually:
cd ~/sd/Demo-Scadfold-project
./deploy.sh
Refresh your browser and make sure the app still works.
Part 5: Install the Self-Hosted Runner
In GitHub, open your repository.
Go to:
Settings → Actions → Runners → New self-hosted runner
Choose:
Linux
Choose architecture:
x64
GitHub will show you commands.
On 10.192.145.179, run the commands in your home directory.
Recommended location:
mkdir -p ~/actions-runner
cd ~/actions-runner
Then follow the GitHub instructions.
When GitHub asks for labels, you may accept the defaults or add this label:
cd
When setup is complete, start the runner:
./run.sh
Leave it running while testing.
For today, it is fine to run the runner manually.
Part 6: Decide When Deployment Should Happen
For this class, deployment should not happen on every branch.
Use:
cdsetup
while learning.
Later, you can change the workflow to deploy from:
main
For today, we will deploy from:
cdsetup
Part 7: Add the CD Workflow
Create this file:
.github/workflows/cd.yml
Use this workflow:
name: CD
on:
workflow_run:
workflows: ["CI"]
types:
- completed
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'cdsetup' }}
runs-on: self-hosted
steps:
- name: Deploy on self-hosted runner
run: |
cd ~/sd/Demo-Scadfold-project
./deploy.sh
This version says:
Run CI first
Only deploy if CI succeeds
Only deploy from cdsetup
Run deployment on the self-hosted runner
Update, rebuild, and restart locally on 10.192.145.179
This is the firewall-safe version of CD.
Part 8: Test the Deployment Flow
Make a small visible change.
For example:
Change the page title
Change a line of text on the frontend
Change a heading
Commit and push:
git add .
git commit -m "Test CD deployment"
git push origin cdsetup
Then watch GitHub Actions.
You should see:
CI starts first
CI passes
CD starts after CI
CD runs on the self-hosted runner
The app updates on 10.192.145.179:41xx
This is the live proof that CD is working.
Part 9: What to Watch During Class
In GitHub:
CI starts first
CD starts after CI
Each step is visible
Failure is visible
On the server:
The deployed repo updates
The frontend is rebuilt
The PM2 process restarts
In the browser:
The visible change appears live
That closes the loop.
What Could Go Wrong
1. CD Does Not Start
Check:
Is the CI workflow named exactly CI?
Did CI finish successfully?
Are you pushing to cdsetup?
Is the self-hosted runner online?
2. GitHub Says No Runner Is Available
Go to the runner directory:
cd ~/actions-runner
Start the runner:
./run.sh
Then check GitHub:
Settings → Actions → Runners
The runner should show as online.
3. Deployment Cannot Find the Project Directory
Check the path in cd.yml:
cd ~/sd/Demo-Scadfold-project
Make sure that directory actually exists on the server.
4. git pull Fails
Check:
Is the repo cloned correctly?
Is it on the cdsetup branch?
Does the server have permission to pull from GitHub?
Are there uncommitted local changes blocking the pull?
Useful commands:
git status
git branch
git remote -v
5. PM2 Restart Fails
Check:
pm2 list
Make sure the process name matches the deploy script.
If your PM2 app is named something else, update this line in deploy.sh:
pm2 restart demo-scaffold
6. Frontend Builds Locally But Not on Server
Check:
Did you run npm install in the client directory?
Are all dependencies listed in package.json?
Does npm run build work manually?
7. CD Runs But the Page Does Not Change
Check:
Did npm run build complete successfully?
Is Express serving client/dist?
Are you looking at the correct 41xx port?
Try a hard refresh in the browser.
Suggested AI Prompts for CD
Prompt 1: Explain the Deployment Design
I have a React + Express single-port application.
The backend serves the built frontend.
The server is behind a firewall, so GitHub-hosted Actions runners cannot SSH into it.
I want to use a GitHub Actions self-hosted runner on the server.
Explain this CD design in plain English before giving code.
Prompt 2: Generate the CD Workflow
Write a GitHub Actions CD workflow for a React + Express single-port app.
The app is deployed on a Linux server behind a firewall.
Use a self-hosted GitHub Actions runner.
Deployment should happen only after the CI workflow succeeds on the cdsetup branch.
The deployment command should cd into the deployed repo and run ./deploy.sh.
Return only valid YAML.
Prompt 3: Generate the Deployment Script
Write a deploy.sh script for a React + Express app.
The script should:
checkout cdsetup,
pull the latest code,
install root dependencies,
install and build the client,
install server dependencies,
and restart the app with PM2.
Use set -e so the script stops on errors.
Prompt 4: Debug Deployment Failure
Here is my GitHub Actions CD workflow, my deploy.sh file, and the exact error output.
Tell me the most likely cause and the smallest fix.
Do not propose a major redesign unless necessary.
Prompt 5: Refine Deployment Safety
I already have CI and a simple CD workflow using a self-hosted runner.
Show me how to make deployment happen only after CI succeeds on a specific branch.
Return only the updated workflow YAML and a short explanation.
In-Class Reflection Questions
What is the difference between CI and CD?
Why do we want CD to depend on CI?
Why does the firewall change the deployment design?
Why does a self-hosted runner solve the firewall problem?
Why do we deploy from one branch instead of every branch?
Why is PM2 useful here?
What part of this pipeline is still fragile or simplistic?
