THE ONLY DEPLOYMENT GUIDE YOU NEED
End-to-end deployment with example

Living an open source life!
Hey readers, welcome back.
Today we are going to talk about deployment.
And I know, the moment someone says deployment, suddenly ten scary words enter the room.
CI/CD. Docker. Server. SSH. PM2. GitHub Actions. Reverse proxy. Domain. SSL. Caddy.
And if you are learning this for the first time, it feels like everyone is explaining the tools, but nobody is explaining the pain.
Someone says:
“Just write a workflow file.”
Okay bro, but why?
Someone says:
“Use Docker.”
Okay, but what problem did Docker solve?
Someone says:
“Put Caddy in front of your app.”
Nice. In front where? Like physically?
So in this blog, we are not going to start with definitions.
We are going to start with one tiny app, deploy it badly first, feel the pain, and then slowly improve it.
Because that is how DevOps actually starts making sense.
Not by memorizing tools.
By understanding what becomes painful when humans keep doing deployment manually.
The small app we will use throughout this blog
We will keep one example throughout the whole guide.
No giant project. No database. No frontend. No authentication. No Next.js build drama.
Just one simple Node.js server.
It will have three routes:
/ -> main route users open
/version -> tells which version is running
/health -> tells whether the app is alive
That is it.
Here is our server.js:
import express from "express";
const app = express();
const PORT = process.env.PORT || 3000;
const VERSION = process.env.APP_VERSION || "version-one";
app.get("/", (req, res) => {
res.send("Hello from our tiny deployment app");
});
app.get("/version", (req, res) => {
res.json({ version: VERSION });
});
app.get("/health", (req, res) => {
res.status(200).json({ ok: true, service: "deployment-demo" });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
And here is the package.json:
{
"name": "deployment-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "latest"
}
}
Run it locally:
npm install
npm start
Then test it:
curl http://localhost:3000/
curl http://localhost:3000/version
curl http://localhost:3000/health
Now the app is running on your laptop.
But your laptop is not production.
If you close your laptop, the app is gone.
If your Wi-Fi goes down, the app is gone.
If someone from another country wants to open your app, they cannot reliably depend on your laptop.
So we need a machine that stays online.
That machine is what we usually call a server.
First understand the real deployment problem
Let us say version one of the app is ready.
You put it on a server.
Nice.
Tomorrow, the developer changes one word.
For example, in / route:
app.get("/", (req, res) => {
res.send("Hello from our tiny deployment app v2");
});
Tiny change.
Just added v2.
Now that change has to reach the server.
So you SSH into the server.
You pull the code.
You install dependencies again maybe.
You restart the app.
You check if the app is working.
Cool.
Now next day, another developer changes /version:
const VERSION = process.env.APP_VERSION || "version-two";
Again you SSH. Again you pull. Again you restart. Again you check.
Now one day, someone pushes a broken update.
For example:
app.get("/", (req, res) => {
throw new Error("Someone broke the home route");
});
Now the app may still technically start.
The /health route may even respond.
But the main / route is broken.
This is where deployment gets serious.
Deployment is not just:
“Did I copy code to the server?”
Deployment is:
“Did the right code reach the server, start correctly, and still behave correctly after release?”
That is the whole point of this guide.
We want to move from:
Human manually doing the same risky steps again and again
to:
A repeatable automated deployment flow that checks things before and after release
That journey is CI/CD.
Part 1: What DevOps means here
The term DevOps sounds big.
But for us, let us keep it simple.
Development means writing the app.
Operations means running the app somewhere safely.
The developer says:
“I wrote the code.”
The server says:
“Nice. But can I run it?”
The user says:
“I do not care about your code. I only care whether the website opens.”
DevOps sits between these worlds.
It asks:
how does code go from laptop to server?
who checks if the code is broken?
who restarts the app?
who manages environment variables?
who opens the correct ports?
who handles HTTPS?
who makes sure the app comes back after server reboot?
who checks logs when things fail?
Earlier, a person used to do most of this manually.
And for one deployment, manual work is okay.
But after ten deployments, it becomes irritating.
After fifty deployments, it becomes dangerous.
Because humans forget steps.
Humans get tired.
Humans type wrong commands.
Humans say “just this once” and break production.
So we automate.
Not because automation is cool.
Because repetition plus risk is a bad combination.
Part 2: CI/CD in simple words
CI/CD is not one tool.
It is a way of thinking.
CI means Continuous Integration
Continuous Integration means:
Whenever code changes, automatically check whether the code is still okay.
For our tiny server, CI can do simple checks like:
npm ci
node --check server.js
For bigger apps, CI may also run:
npm test
npm run lint
npm run build
CI asks:
“Can this code be trusted enough to move forward?”
It does not always deploy.
It just checks.
CD means Continuous Delivery or Continuous Deployment
People use CD in two slightly different ways.
Continuous Delivery means:
The app is prepared for release automatically, but a human may click the final deploy button.
Continuous Deployment means:
The app is automatically deployed when checks pass.
For this guide, when we say CI/CD, we mostly mean:
When I push code to GitHub, GitHub Actions should build it, ship it to the server, restart it, and check if it is alive.
That is enough for now.
Part 3: Getting a server
To deploy our tiny server, we need an actual server machine.
You can rent one from:
AWS EC2
DigitalOcean Droplet
Linode
Vultr
Hetzner
Google Cloud VM
Azure VM
For this blog, assume Ubuntu server.
After creating the server, you usually get something like:
IP address: 12.34.56.78
Username: ubuntu
Now we need to enter that server.
For that, we use SSH.
Part 4: SSH — entering the server
SSH stands for Secure Shell.
In human words:
SSH lets you open the terminal of a remote server from your own laptop.
The command looks like:
ssh ubuntu@12.34.56.78
But we need security.
We do not want anyone on the internet to enter our server.
So we use SSH keys.
An SSH key pair has two parts:
private key -> stays on your laptop
public key -> goes on the server
Generate a key:
ssh-keygen -t ed25519 -C "your-email@example.com"
This creates files like:
~/.ssh/id_ed25519
~/.ssh/id_ed25519.pub
The .pub one is public.
You can share it with the server.
The private key is private.
Do not share it.
On the server, the public key goes into:
~/.ssh/authorized_keys
After that, your laptop can connect to the server.
But one more thing matters.
The cloud firewall must allow SSH traffic.
Part 5: Ports and security groups
A server has many ports.
Think of ports like doors.
22 -> SSH
80 -> HTTP
443 -> HTTPS
3000 -> common Node development port
Cloud providers usually give you firewall rules or security groups.
Security groups decide which ports are open.
For SSH, we need:
TCP 22
For web traffic, we need:
TCP 80
TCP 443
For learning, people often open SSH like this:
22 from 0.0.0.0/0
This means:
Anyone on the internet can try to reach SSH.
It is convenient, but not ideal.
Better is:
22 from your-ip/32
For public websites, ports 80 and 443 usually need to be open to everyone:
80 from 0.0.0.0/0
443 from 0.0.0.0/0
Later when we use Caddy, users will hit port 80 or 443.
Caddy will forward traffic internally to our Node app.
Users should not directly hit Node on port 3000.
That idea will become important later.
Part 6: First manual deployment
Let us deploy manually first.
Not because it is best.
Because it teaches the pain.
SSH into the server:
ssh ubuntu@12.34.56.78
Update the server:
sudo apt update
sudo apt upgrade -y
Install Git:
sudo apt install git -y
Install Node.js.
You can install Node using NodeSource, nvm, or your preferred method.
For now, assume Node and npm are available:
node -v
npm -v
Now clone the app:
git clone https://github.com/your-username/deployment-demo.git
cd deployment-demo
Install dependencies:
npm install
Start the server:
npm start
Now test it from another terminal:
curl http://12.34.56.78:3000/
curl http://12.34.56.78:3000/version
curl http://12.34.56.78:3000/health
If port 3000 is blocked in firewall, this will not work from outside.
You could open port 3000, but that is not what we want long term.
For now, the bigger problem is different.
The moment you close your SSH session, your Node process may stop.
That is not production.
We need the app to keep running.
Enter PM2.
Part 7: PM2 — keeping the app alive
PM2 is a process manager for Node.js.
Human meaning:
“Run this app in the background, restart it if it crashes, and keep it alive after I close the terminal.”
Install PM2:
sudo npm install -g pm2
Start the app:
pm2 start npm --name "deployment-demo" -- start
Check status:
pm2 status
Check logs:
pm2 logs deployment-demo
Restart:
pm2 restart deployment-demo
Stop:
pm2 stop deployment-demo
Save the process list:
pm2 save
Setup startup after reboot:
pm2 startup
PM2 is useful.
Now if the app crashes, PM2 can restart it.
But let us not get overexcited.
PM2 solves the “keep process alive” problem.
It does not solve the “deploy safely every time” problem.
Part 8: Our first version update
Now we will use our tiny app to understand deployment updates.
Current / route:
app.get("/", (req, res) => {
res.send("Hello from our tiny deployment app");
});
Developer changes one word:
app.get("/", (req, res) => {
res.send("Hello from our tiny deployment app v2");
});
Now the update has to reach the server.
Manual deployment steps:
cd deployment-demo
git pull origin main
npm install
pm2 restart deployment-demo
curl -f http://localhost:3000/health
curl -f http://localhost:3000/
curl -f http://localhost:3000/version
If everything works, users see the new text.
Fine.
But now think realistically.
Will you type this every time?
What if you forget npm install?
What if the app fails after restart?
What if the /health route works but / is broken?
What if you deploy at 2 AM and type the wrong command?
Manual deployment is okay for learning.
It is not okay as a system.
Part 9: Our first crash update
Now let us intentionally break the app.
Someone changes / route to this:
app.get("/", (req, res) => {
throw new Error("Someone broke the home route");
});
Important thing:
The app may still start.
PM2 may still show it as online.
/health may still return:
{ "ok": true, "service": "deployment-demo" }
But / is broken.
So after deployment, checking only process status is not enough.
This is why we use smoke tests.
A smoke test is a small basic check after deployment.
For our app:
curl -f http://localhost:3000/health
curl -f http://localhost:3000/
curl -f http://localhost:3000/version
These checks do not prove the whole app is perfect.
But they catch obvious failures.
That is already better than blind deployment.
Now we know the repeated steps.
So we can automate them.
Part 10: GitHub Actions enters
GitHub Actions lets us run commands when something happens in a GitHub repository.
For example:
When someone pushes to main -> run this workflow
Workflow files live here:
.github/workflows/deploy.yml
A very small CI workflow:
name: CI
on:
push:
branches:
- main
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Check syntax
run: node --check server.js
Let us decode this slowly.
on:
push:
branches:
- main
This means:
Run this workflow when code is pushed to
main.
runs-on: ubuntu-latest
This means GitHub creates a temporary Ubuntu machine.
Not your laptop.
Not your server.
A fresh temporary machine.
uses: actions/checkout@v4
This downloads your repository code into that machine.
uses: actions/setup-node@v4
This installs Node.js in that temporary machine.
npm ci
This installs dependencies using package-lock.json.
For CI, npm ci is usually better than npm install because it gives clean predictable installs.
node --check server.js
This checks if server.js has syntax errors.
For real projects, you should add tests too.
But for our tiny demo, this is enough to understand CI.
Now we have automated checking.
But still no deployment.
Part 11: Deploying with GitHub Actions and PM2
Now we want GitHub Actions to SSH into the server and run the same commands we were typing manually.
The script is basically:
cd /home/ubuntu/deployment-demo
git pull origin main
npm ci
pm2 restart deployment-demo
curl -f http://localhost:3000/health
curl -f http://localhost:3000/
curl -f http://localhost:3000/version
But GitHub Actions needs permission to enter the server.
So we create GitHub Secrets.
Go to:
GitHub repo -> Settings -> Secrets and variables -> Actions
Add:
SERVER_HOST
SERVER_USER
SERVER_SSH_KEY
Example:
SERVER_HOST = 12.34.56.78
SERVER_USER = ubuntu
SERVER_SSH_KEY = private SSH key that can access the server
Never commit private keys.
Never paste secrets inside code.
Now a basic PM2 deployment workflow:
name: Deploy with PM2
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy on server
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /home/ubuntu/deployment-demo
git pull origin main
npm ci
pm2 restart deployment-demo
sleep 3
curl -f http://localhost:3000/health
curl -f http://localhost:3000/
curl -f http://localhost:3000/version
Now the flow is better.
Developer changes one word in /.
Pushes to GitHub.
GitHub Actions connects to server.
Server pulls code.
PM2 restarts app.
Smoke tests run.
If checks fail, workflow becomes red.
This is a real improvement.
But it still has a weakness.
The server environment matters too much.
Part 12: The problem with direct server deployment
With the PM2 approach, your server must have everything installed correctly:
Node.js
npm
PM2
Git
correct Node version
correct environment variables
correct system packages
correct PATH setup
This works until it does not.
One common issue:
You SSH manually and run:
npm -v
It works.
But GitHub Actions SSH command says:
npm: command not found
Why?
Because your manual SSH session is interactive.
It may load:
~/.bashrc
~/.profile
But CI SSH commands often run in a non-interactive shell.
That shell may not load the same environment.
If you installed Node using nvm, you may need:
export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"
inside the deployment script.
Like this:
script: |
export NVM_DIR="$HOME/.nvm"
source "$NVM_DIR/nvm.sh"
cd /home/ubuntu/deployment-demo
git pull origin main
npm ci
pm2 restart deployment-demo
This is fixable.
But notice the larger problem.
The server has too much responsibility.
It has to know how to build and run the app.
Docker helps us reduce that dependency.
Part 13: Why Docker exists here
Docker is not just a trendy tool.
Docker solves a very specific pain:
“It works on my machine, but not on the server.”
Our app needs Node.
It needs npm dependencies.
It needs a start command.
Without Docker, we install these things directly on the server.
With Docker, we package the app and its environment into an image.
Human example:
Without Docker:
“Go to the server kitchen and prepare everything there.”
With Docker:
“Pack the whole kitchen setup and send it to the server.”
A Docker image is the packed app.
A Docker container is the running app.
Image:
yourdockerusername/deployment-demo:latest
Container:
running instance of that image
Now the server does not need Node installed to run our app.
The server needs Docker.
That is cleaner.
Part 14: Dockerfile for our tiny server
Create a Dockerfile in the project:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
Let us understand this.
FROM node:22-alpine
Start with a small Linux image that already has Node 22.
WORKDIR /app
Inside the container, work in /app.
COPY package*.json ./
RUN npm ci
Copy dependency files and install dependencies.
COPY . .
Copy the rest of the app.
EXPOSE 3000
This documents that the app listens on port 3000.
CMD ["npm", "start"]
Run the server when container starts.
That is it.
Our tiny server is now packageable.
Part 15: Add .dockerignore
Create .dockerignore:
node_modules
.git
.github
.env
npm-debug.log
Dockerfile
docker-compose.yml
README.md
Why?
Because Docker sends your project folder as build context.
You do not want to send unnecessary files into the image.
Especially not .env.
Never bake production secrets into your Docker image.
Part 16: Build and run Docker locally
Build the image:
docker build -t deployment-demo .
Run it:
docker run --name deployment-demo -p 3000:3000 deployment-demo
Test:
curl http://localhost:3000/
curl http://localhost:3000/version
curl http://localhost:3000/health
Stop it:
docker stop deployment-demo
Remove it:
docker rm deployment-demo
Run in background:
docker run -d --name deployment-demo -p 3000:3000 deployment-demo
Check logs:
docker logs -f deployment-demo
Now our app is running inside a container.
The mental model changed.
Earlier:
server runs Node directly
Now:
server runs Docker container
container runs Node app
Good.
Now we need to send this image somewhere the server can pull from.
Part 17: Docker Hub — storing the image
A container registry stores Docker images.
Common registries:
Docker Hub
GitHub Container Registry
AWS ECR
Google Artifact Registry
We will use Docker Hub because it is beginner friendly.
Login:
docker login
Tag your image:
docker tag deployment-demo yourdockerusername/deployment-demo:latest
Push it:
docker push yourdockerusername/deployment-demo:latest
Now the server can pull it:
docker pull yourdockerusername/deployment-demo:latest
Run on server:
docker run -d \
--name deployment-demo \
--restart unless-stopped \
-p 80:3000 \
yourdockerusername/deployment-demo:latest
This maps:
server port 80 -> container port 3000
So users can open:
http://server-ip
and reach the app.
But running long Docker commands manually is again not ideal.
We automated one pain and created another.
This is where Docker Compose helps.
Part 18: Docker Compose — writing the server setup once
Docker Compose lets us define containers in a file.
Instead of typing:
docker run -d --name blah blah blah very long command
we write a docker-compose.yml.
On the server, create a clean app folder:
mkdir -p /home/ubuntu/apps/deployment-demo
cd /home/ubuntu/apps/deployment-demo
Create docker-compose.yml:
services:
app:
image: yourdockerusername/deployment-demo:latest
container_name: deployment-demo-app
restart: unless-stopped
environment:
APP_VERSION: version-one
PORT: 3000
expose:
- "3000"
caddy:
image: caddy:2-alpine
container_name: deployment-demo-caddy
restart: unless-stopped
depends_on:
- app
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
Notice this line:
expose:
- "3000"
We are not doing:
ports:
- "3000:3000"
Why?
Because we do not want users to directly talk to Node.
Users should talk to Caddy.
Caddy should talk to Node internally.
That gives us a cleaner setup.
Flow:
User -> Caddy -> Node container
Part 19: Caddy — reverse proxy and HTTPS
Caddy is a web server and reverse proxy.
For us, it sits in front of the Node app.
It receives internet traffic on ports 80 and 443.
Then it forwards traffic to the app container.
Create Caddyfile in the same server folder:
yourdomain.com {
reverse_proxy app:3000
}
That is shockingly small.
Here app is not random.
It is the service name from Docker Compose:
services:
app:
Docker Compose puts services on the same network.
So Caddy can reach the app using:
app:3000
Now run:
docker compose up -d
Check containers:
docker ps
Check logs:
docker logs -f deployment-demo-caddy
If your domain points to your server and ports 80 and 443 are open, Caddy can automatically get HTTPS certificates.
So instead of:
http://server-ip
you get:
https://yourdomain.com
This is why Caddy is loved.
It removes SSL certificate headache for common deployments.
Part 20: Domain and DNS
A domain is a human-friendly name for your server.
Instead of making users remember:
12.34.56.78
they remember:
yourdomain.com
To connect the domain to your server, go to DNS settings and add an A record:
Type: A
Name: @
Value: your-server-ip
For www, add:
Type: CNAME
Name: www
Value: yourdomain.com
or another A record:
Type: A
Name: www
Value: your-server-ip
DNS may take time.
To check:
dig yourdomain.com
or:
nslookup yourdomain.com
If it returns your server IP, DNS is pointing correctly.
For Caddy HTTPS to work:
domain must point to server
port
80must be openport
443must be openCaddy container must be running
Part 21: GitHub Actions with Docker
Now we have the better deployment model.
When code changes:
GitHub Actions builds Docker image.
It pushes image to Docker Hub.
It SSHs into server.
Server pulls latest image.
Docker Compose restarts containers.
Smoke tests check
/health,/, and/version.
This is a much cleaner CI/CD flow.
Add GitHub Secrets:
DOCKERHUB_USERNAME
DOCKERHUB_TOKEN
SERVER_HOST
SERVER_USER
SERVER_SSH_KEY
Use Docker Hub access token, not your normal password.
Now create:
.github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
IMAGE_NAME: yourdockerusername/deployment-demo
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Check syntax
run: node --check server.js
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
\({{ env.IMAGE_NAME }}:\){{ github.sha }}
- name: Deploy on server
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /home/ubuntu/apps/deployment-demo
docker compose pull
docker compose up -d
docker image prune -f
sleep 5
curl -f http://localhost/health
curl -f http://localhost/
curl -f http://localhost/version
Now let us map this back to our update example.
Developer changes:
res.send("Hello from our tiny deployment app");
to:
res.send("Hello from our tiny deployment app v2");
Pushes code.
GitHub Actions runs.
Docker image is built.
Image is pushed.
Server pulls it.
Compose restarts the app.
Smoke tests run.
If /, /version, and /health respond, deployment is green.
Now broken update:
app.get("/", (req, res) => {
throw new Error("Someone broke the home route");
});
The workflow deploys, then runs:
curl -f http://localhost/
If / returns an error status, the workflow fails.
That is not perfect rollback.
But it is already better than deploying blindly and sleeping peacefully while production is broken.
Part 22: Why we tag both latest and commit SHA
In the workflow, we used:
tags: |
yourdockerusername/deployment-demo:latest
yourdockerusername/deployment-demo:${{ github.sha }}
Why not only latest?
Because latest is convenient but vague.
If something breaks, you will ask:
“Which code is actually running?”
The commit SHA tag gives every image a unique identity.
Example:
yourdockerusername/deployment-demo:a1b2c3d...
So rollback becomes easier.
You can change the image in docker-compose.yml:
image: yourdockerusername/deployment-demo:a1b2c3d
Then run:
docker compose up -d
This is not fancy enterprise rollback.
But for beginner projects and MVPs, it is a very practical habit.
Part 23: Environment variables
Our server uses:
const VERSION = process.env.APP_VERSION || "version-one";
That means we can control the version from environment.
In Docker Compose:
environment:
APP_VERSION: version-one
PORT: 3000
For real projects, you may have secrets:
DATABASE_URL
JWT_SECRET
API_KEY
Do not put secrets in code.
Do not put production .env in GitHub.
For runtime secrets, keep them on the server or use a secret manager.
You can also use an .env file with Compose:
services:
app:
env_file:
- .env
Then on server:
nano /home/ubuntu/apps/deployment-demo/.env
Example:
NODE_ENV=production
PORT=3000
APP_VERSION=version-one
For GitHub Actions secrets, use GitHub Secrets.
For app runtime secrets, use server .env or a proper secret manager.
Keep these two worlds separate.
Part 24: Installing Docker on the server
Your server needs Docker and Docker Compose plugin.
On Ubuntu, the clean approach is to install Docker from Docker’s official repository.
Basic idea:
sudo apt update
sudo apt install ca-certificates curl -y
Then add Docker’s official repository as per current Docker docs.
Then install:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y
Check:
docker --version
docker compose version
Add your user to docker group:
sudo usermod -aG docker $USER
Then log out and log in again.
If you skip this, you may need sudo docker every time.
For CI/CD SSH scripts, it is cleaner if the deploy user can run Docker commands without password.
Part 25: The final server folder
On the server, keep deployment files in one place:
/home/ubuntu/apps/deployment-demo/
├── docker-compose.yml
├── Caddyfile
└── .env
This folder does not need your full source code.
Why?
Because the source code is converted into a Docker image by GitHub Actions.
The server only needs to know:
which image to run
which ports to expose
which reverse proxy config to use
which env vars to pass
This is cleaner than cloning the whole repo and building on the server.
Part 26: Final project files
Your GitHub repo should look like this:
deployment-demo/
├── .github/
│ └── workflows/
│ └── deploy.yml
├── .dockerignore
├── Dockerfile
├── package.json
├── package-lock.json
└── server.js
Your server should look like this:
/home/ubuntu/apps/deployment-demo/
├── docker-compose.yml
├── Caddyfile
└── .env
GitHub Secrets:
DOCKERHUB_USERNAME
DOCKERHUB_TOKEN
SERVER_HOST
SERVER_USER
SERVER_SSH_KEY
Cloud firewall:
22 -> SSH, ideally only your IP
80 -> HTTP, public
443 -> HTTPS, public
DNS:
A record: @ -> server IP
optional www -> server IP
Part 27: Final complete files
server.js
import express from "express";
const app = express();
const PORT = process.env.PORT || 3000;
const VERSION = process.env.APP_VERSION || "version-one";
app.get("/", (req, res) => {
res.send("Hello from our tiny deployment app");
});
app.get("/version", (req, res) => {
res.json({ version: VERSION });
});
app.get("/health", (req, res) => {
res.status(200).json({ ok: true, service: "deployment-demo" });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Successful update example:
app.get("/", (req, res) => {
res.send("Hello from our tiny deployment app v2");
});
Crash update example:
app.get("/", (req, res) => {
throw new Error("Someone broke the home route");
});
package.json
{
"name": "deployment-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "latest"
}
}
Dockerfile
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=deps /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
.dockerignore
node_modules
.git
.github
.env
npm-debug.log
Dockerfile
docker-compose.yml
README.md
docker-compose.yml on server
services:
app:
image: yourdockerusername/deployment-demo:latest
container_name: deployment-demo-app
restart: unless-stopped
env_file:
- .env
expose:
- "3000"
caddy:
image: caddy:2-alpine
container_name: deployment-demo-caddy
restart: unless-stopped
depends_on:
- app
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
Caddyfile
yourdomain.com {
reverse_proxy app:3000
}
.env on server
NODE_ENV=production
PORT=3000
APP_VERSION=version-one
.github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
IMAGE_NAME: yourdockerusername/deployment-demo
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Check syntax
run: node --check server.js
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
\({{ env.IMAGE_NAME }}:\){{ github.sha }}
- name: Deploy on server
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /home/ubuntu/apps/deployment-demo
docker compose pull
docker compose up -d
docker image prune -f
sleep 5
curl -f http://localhost/health
curl -f http://localhost/
curl -f http://localhost/version
Part 28: Debugging without panic
When deployment fails, do not randomly change everything.
Debug layer by layer.
Is GitHub Actions running?
Go to:
GitHub repo -> Actions
If YAML indentation is broken, GitHub will tell you.
Did dependencies install?
Look for:
npm ci failed
package-lock.json missing
wrong Node version
Did Docker image build?
Check the Build and push image step.
If Dockerfile is wrong, it fails there.
Did image push to Docker Hub?
Check Docker Hub.
If login failed, check:
DOCKERHUB_USERNAME
DOCKERHUB_TOKEN
Can GitHub SSH into server?
If SSH fails, check:
SERVER_HOST
SERVER_USER
SERVER_SSH_KEY
~/.ssh/authorized_keys
port 22 firewall
Is Docker installed on server?
SSH manually:
docker --version
docker compose version
Are containers running?
docker ps
Is app crashing?
docker logs -f deployment-demo-app
Is Caddy working?
docker logs -f deployment-demo-caddy
Is DNS correct?
dig yourdomain.com
Are ports open?
Cloud firewall should allow:
80
443
That is debugging.
Not magic.
Just layers.
Part 29: What not to do
Do not delete random server files every time you deploy.
Do not store secrets in GitHub code.
Do not put production .env inside Docker image.
Do not expose your database publicly.
Do not expose Node directly to the internet if you can put Caddy in front.
Do not rely only on “container started”.
Do not trust latest blindly when rollback matters.
Do not keep SSH open to the whole world forever if you can restrict it.
Do not deploy without at least basic smoke checks.
Small discipline here saves huge pain later.
Part 30: What did we actually learn?
We started with a tiny server.
Only three routes:
/
/version
/health
Then we deployed it manually.
Then we saw the pain.
Then PM2 helped us keep the app alive.
Then GitHub Actions helped us automate the repeated steps.
Then Docker helped us package the app with its environment.
Then Docker Hub helped us store the image.
Then Docker Compose helped us run the app and reverse proxy cleanly.
Then Caddy helped us add HTTPS without certificate drama.
Then health checks helped us avoid deploying blindly.
That is the real story.
CI/CD is not about YAML.
Docker is not about memorizing commands.
Caddy is not about looking senior.
All of these tools exist because manual deployment becomes repetitive, risky, and annoying.
Once you feel the pain, the tools become obvious.
And that is how you should learn deployment.
Not by memorizing buzzwords.
By asking at every step:
“What pain are we removing now?”
That one question will make DevOps much easier for you.
Practice task
Take this same tiny server and deploy it in four levels.
Level 1: Manual server deployment
Run:
npm start
on your server.
Level 2: PM2 deployment
Run:
pm2 start npm --name "deployment-demo" -- start
Then update one word in / and redeploy manually.
Level 3: Docker deployment
Build and run:
docker build -t deployment-demo .
docker run -p 3000:3000 deployment-demo
Level 4: Full CI/CD
Use:
GitHub Actions
Docker Hub
Docker Compose
Caddy
Domain
HTTPS
Smoke tests
When you finish Level 4, you will not just know the commands.
You will understand why the commands exist.
And that is the actual win.
See you in the next one.

