The Problem: Why You Need to Know Deployment
As a student or intern, you might be entirely focused on writing code. Building cool features, fixing bugs, and mastering a framework are all great, but what happens when you need to show your work to the world? Lacking knowledge about deployment and server hosting can put you at a significant disadvantage.
In today's competitive tech industry, companies look for developers who understand the full lifecycle of software. Understanding how your code gets from your local machine to a live server is a crucial skill. It helps you debug production issues, optimize for performance, and prevents you from being left behind or easily replaced. Knowing how to deploy is the difference between a "coder" and a "software engineer."
The Workflow We Will Build
In this guide, we will walk through a simple CI/CD (Continuous Integration / Continuous Deployment) workflow to get your application live. By the end of this, you will understand how a basic system operates.
- Setup Server: We will rent or build a Linux server and install necessary tools (
git,docker,docker-compose). - User & Permissions: We will create a dedicated Linux user specifically for running our app and set up SSH keys for secure access.
- Setup Registry: We will configure a container registry (using Zot) directly on the server to store our Docker images efficiently and securely.
- GitHub Access: We will allow GitHub Actions to access your server via SSH securely.
- CI/CD Pipeline: We will create a GitHub Actions workflow to automate the build, push, and deploy process every time you merge code.
- Public Access: Finally, we will expose your server to the internet so anyone can visit your app.
Step 1: Setting Up the Linux Server
First, you need a Linux server. If you don't have a physical machine to use as a server, you can rent a VPS (Virtual Private Server) from providers like DigitalOcean, Linode, AWS, or Azure for just a few dollars a month. Ubuntu is the most common OS, so we will use it for this guide.
Once you SSH into your server as the root user, update your system packages and install the essential tools: git and docker.
# Update package manager and upgrade existing packages
sudo apt update && sudo apt upgrade -y
# Install Git
sudo apt install git -y
# Download and run the official Docker installation script
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Verify Docker installation
docker --version
Installing Docker also installs docker compose, which we will use to manage our containers easily.
Step 2: Creating a Dedicated User and SSH Key
For security reasons, running your application as the root user is a huge risk. If your app gets compromised, the attacker has full control of your server. Let's create a dedicated user and give them permissions to run Docker.
# Create a new user (let's call it 'deployer')
sudo adduser deployer
# Add 'deployer' to the 'docker' group so they can run docker commands without sudo
sudo usermod -aG docker deployer
# Switch to the new user account
su - deployer
Now, we need to generate an SSH key. This key will be used by GitHub Actions to securely log into your server and run deployment commands.
# Generate an SSH key pair
ssh-keygen -t ed25519 -C "github-actions-deploy"
This creates two files: a public key (id_ed25519.pub) and a private key (id_ed25519). You need to add the public key to your server's authorized keys list so it recognizes the login attempt:
# Add public key to authorized_keys
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Then, copy the contents of the private key (cat ~/.ssh/id_ed25519). You will paste this into your GitHub Repository's Settings > Secrets and variables > Actions as a new secret named SERVER_SSH_KEY.
Step 3: Setting Up a Container Registry (Zot) with Authentication
A container registry is where you upload your built Docker images so your server can download and run them. Docker Hub only allows one free private repository. To save money and keep all your project images private, we can self-host a registry.
Zot is an excellent, lightweight registry. We will run it on our server using Docker, but we must secure it. If you expose a registry without authentication, anyone can download your private application code or upload malicious images.
3.1 Generating an htpasswd File
First, we need to create a username and password for GitHub Actions to authenticate. We use the htpasswd utility via a temporary Docker container to generate it securely:
# Run this in your deployment folder
docker run --rm -v $(pwd):/work httpd:alpine htpasswd -b -B -c /work/htpasswd example-user 'your-strong-password'
This creates a file named htpasswd in your current directory containing the hashed password.
3.2 Configuring Zot
Next, create a config.json file to tell Zot where to store data and how to authenticate users:
{
"distSpecVersion": "1.1.0",
"storage": {
"rootDirectory": "/var/lib/registry"
},
"http": {
"address": "0.0.0.0",
"port": "5000",
"readTimeout": "10m",
"writeTimeout": "10m",
"auth": {
"htpasswd": {
"path": "/etc/zot/htpasswd"
},
"failDelay": 5
}
},
"log": {
"level": "info"
},
"extensions": {
"search": {
"enable": true
},
"ui": {
"enable": true
}
}
}
3.3 Running Zot via Docker Compose
Create a docker-compose.yml specifically for Zot (or add it to your main compose file):
version: '3.8'
services:
zot:
image: ghcr.io/project-zot/zot:latest
container_name: zot_registry
restart: unless-stopped
ports:
- "5000:5000"
volumes:
- ./zot-data:/var/lib/registry
- ./config.json:/etc/zot/config.json:ro
- ./htpasswd:/etc/zot/htpasswd:ro
user: "1000:1000"
Start the registry by running docker compose up -d. Now, your registry is running securely on port 5000.
3.4 Security Risks & Best Practices
While Basic Auth (htpasswd) adds a layer of security, exposing port 5000 directly to the internet is risky because GitHub Actions needs access to it. Here are the risks and how to mitigate them:
- Risk of Brute Force: Public ports are constantly scanned. An attacker might try to brute-force your registry password.
- Man-in-the-Middle (MITM): If you don't use HTTPS (TLS/SSL), your password is sent in plain text. Always put a Reverse Proxy (like Nginx) with an SSL certificate in front of Zot, even for GitHub Actions!
- IP Whitelisting: Instead of exposing the registry to the whole world, configure your firewall (UFW) or Nginx to only allow traffic from GitHub Actions' IP ranges. Alternatively, you can use Cloudflare WAF to challenge or block non-GitHub traffic.
- VPC (Virtual Private Cloud) / Private Networking: The most secure enterprise approach is to not expose the registry to the internet at all. You would set up a VPN or a self-hosted GitHub Actions Runner inside your server's VPC. The runner builds the image and pushes it to the registry purely over the internal private network.
Step 4: The GitHub Branching Strategy
If your CI/CD pipeline runs every time you push a minor commit to your main branch, you will waste a lot of build minutes and risk deploying broken code. Instead, we create a branch dedicated purely to deployment.
# Create and switch to the deploy branch
git checkout -b deploy
# Push it to GitHub
git push origin deploy
Now, whenever you finish a feature on main, you can create a Pull Request to merge main into deploy. The CI/CD pipeline will only trigger on the deploy branch.
Step 5: The GitHub Actions Pipeline & Server Docker Compose
This is where the magic happens. We write a YAML file that tells GitHub exactly what to do.
5.1 Configure GitHub Action Secrets
Before our pipeline can run, it needs credentials to access your registry and your server. Hardcoding these in the YAML file is a massive security risk. Instead, we use GitHub Secrets.
Navigate to your GitHub Repository -> Settings -> Secrets and variables -> Actions and add the following repository secrets:
- APP_NEXTAUTH_URL: The public URL of your application (e.g.,
https://example.com). This is often required by frameworks like Next.js for authentication setup. - REGISTRY_HOST: The IP or domain (and port) of your Zot registry (e.g.,
registry.example.com:5000). - REGISTRY_USERNAME: The username you created for Zot using
htpasswd. - REGISTRY_PASSWORD: The strong password you created for your Zot registry user.
- SERVER_HOST: The public IP address or domain of your target Linux server.
- SERVER_USER: The dedicated deployment user we created in Step 2 (e.g.,
deployer). - SERVER_SSH_KEY: The private SSH key (
~/.ssh/id_ed25519) we generated on the server so GitHub Actions can securely log in via SSH. - SERVER_DEPLOY_PATH: The absolute path on your server where your project's
docker-compose.ymland.envreside (e.g.,/home/deployer/example-app).
5.2 The Pipeline YAML
Create a file in your project at .github/workflows/deploy.yml:
name: Deploy Example App
on:
push:
branches:
- deploy
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure Docker insecure registry
run: |
echo '{"insecure-registries": ["${{ secrets.REGISTRY_HOST }}"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Login to Zot Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ secrets.REGISTRY_HOST }}/example-web:latest
- name: Deploy to server
uses: appleboy/[email protected]
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd ${{ secrets.SERVER_DEPLOY_PATH }}
docker compose pull
docker compose up -d
On your server, inside the deployment folder (e.g., /home/deployer/example-app), create your docker-compose.yml:
services:
web:
image: registry.example.com/example-web:latest
container_name: example-app
ports:
- "3001:3001"
env_file:
- .env
restart: unless-stopped
depends_on:
- db
networks:
- example-network
db:
image: postgres:16-alpine
container_name: example-db
restart: unless-stopped
environment:
POSTGRES_USER: example_user
POSTGRES_PASSWORD: example_password
POSTGRES_DB: example_db
volumes:
- example_pgdata:/var/lib/postgresql/data
networks:
- example-network
volumes:
example_pgdata:
networks:
example-network:
driver: bridge
Step 6: Exposing Your Server to the Internet
Your app is running on port 3001, but nobody types http://your-ip:3001 to visit a website. Here is how to make it public:
Case 1: Your Server Has a Public IP (VPS)
If you rented a server, it has a public IP. Go to your domain registrar (like Namecheap or GoDaddy) and point your domain's A Record to that IP. Then, on your server, install a Reverse Proxy like Nginx or Traefik. The reverse proxy will listen on ports 80 (HTTP) and 443 (HTTPS), secure the connection with an SSL certificate, and forward the traffic to your app on port 3001.
Case 2: No Public IP (Home Server / Local Machine)
If you are hosting this on an old laptop at home, your home network is hidden behind your router (NAT), and your IP changes dynamically. Do not open ports on your home router! Instead, use Cloudflare Tunnels. A tunnel runs a small service on your server that creates a secure outbound connection to Cloudflare. When a user visits your domain, Cloudflare routes the traffic securely down that tunnel directly to port 3001 on your server. It's free, secure, and requires zero firewall configuration.
By implementing this CI/CD pipeline, you are no longer just writing code—you are delivering software. This workflow is the foundation of modern DevOps, and mastering it early will set you apart from other students and interns.