Skip to main content

Command Palette

Search for a command to run...

THE ONLY DEPLOYMENT GUIDE YOU NEED

End-to-end deployment with example

Updated
•29 min read•View as Markdown
THE ONLY DEPLOYMENT GUIDE YOU NEED
S

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 80 must be open

  • port 443 must be open

  • Caddy container must be running


Part 21: GitHub Actions with Docker

Now we have the better deployment model.

When code changes:

  1. GitHub Actions builds Docker image.

  2. It pushes image to Docker Hub.

  3. It SSHs into server.

  4. Server pulls latest image.

  5. Docker Compose restarts containers.

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