Hey DevOps folks! π I've just wrapped up the DevOps Intern Stage 1 Task from HNG13, inspired by that dev.to challenge post. The mission? Build a single, robust Bash script to automate deploying a Dockerized app to a remote Linux server. I nailed it by deploying to an AWS EC2 instance with a simple Flask app that displays a success message and server time. This setup showcases real-world automation, idempotency, and reliability in DevOps workflows.
In this article, I'll share my deploy.sh script, explain the process, and how it all came together. Everything's based on my actual project files, feel free to check them out and adapt!
Task Overview
The script (deploy.sh) handles everything in one executable file:
- Collect and validate user inputs (Git repo, PAT, branch, SSH details, app port).
- Clone or update the repo.
- Verify Docker files.
- Test SSH and prepare the remote env (install Docker, Compose, Nginx).
- Transfer files via rsync.
- Deploy the app (build/run containers idempotently).
- Set up Nginx reverse proxy.
- Validate with health checks and curls.
- Log everything, handle errors, and support cleanup with
--cleanup.
I used AWS EC2 (Ubuntu 22.04) as the remote server. My app is a basic Flask site in Fapp.py, Dockerized via Dockerfile. Repo includes requirements.txt and a detailed README.md.
Prerequisites
- AWS EC2 instance (e.g., t2.micro Ubuntu) with SSH key access. Open security group ports: 22 (SSH), 80 (HTTP), 8080 (app, direct testing).
- Git repo with the app files:
Fapp.py,Dockerfile,requirements.txt. - Local machine with Git, SSH, rsync.
- PAT for GitHub repo access.
Here's a peek at the app files for context:
Fapp.py (Flask app serving HTML):
from flask import Flask
from datetime import datetime
app = Flask(__name__)
@app.route('/')
def home():
return f'''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stage1 HNG13 Deployment Successful!</title>
<style>
body {{
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #3f8bcd 0%, #2a629a 100%);
color: white;
}}
.container {{
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}}
h1 {{ margin-bottom: 1rem; }}
.timestamp {{ font-size: 0.9em; opacity: 0.8; }}
</style>
</head>
<body>
<div class="container">
<h1>πStage1 HNG13 Deployment Successful!</h1>
<p>Your automated deployment script is working!</p>
<p class="timestamp">Server Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
</div>
</body>
</html>
'''
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "Fapp.py"]
requirements.txt:
Flask==3.0.0
The Bash Script: deploy.sh
This is the heart of it; POSIX-compliant, executable, and fully featured. Run chmod +x deploy.sh first.
#!/bin/bash
set -euo pipefail
# Create timestamped log file
LOG_FILE="deploy_$(date +%Y%m%d_%H%M%S).log"
# Log messages
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
# Trap errors
trap 'log "ERROR: Script failed at line $LINENO"' ERR
# Read input with validation
read_input() {
local prompt="$1"
local var_name="$2"
local default="${3:-}"
read -p "$prompt: " value
value="${value:-$default}"
if [[ -z "$value" && -z "$default" ]]; then
log "ERROR: $var_name cannot be empty"
exit 1
fi
echo "$value"
}
# Gather inputs
GIT_REPO=$(read_input "Enter Git Repository URL" "GIT_REPO")
BRANCH=$(read_input "Enter branch name [main]" "BRANCH" "main")
# PAT: Silent read, validate non-empty
echo -n "PAT: "; stty -echo; read -r PAT; stty echo; echo
[[ -n "$PAT" ]] || { echo "Error: PAT required" >&2; exit 1; }
SSH_USER=$(read_input "Enter SSH username" "SSH_USER")
SERVER_IP=$(read_input "Enter server IP address" "SERVER_IP")
APP_PORT=$(read_input "Enter application port" "APP_PORT")
SSH_KEY=$(read_input "Enter SSH key path" "SSH_KEY")
#: Silent, validate file/permissions
#echo -n "SSH Key Path: "; stty -echo; read -r SSH_KEY; stty echo; echo
#[[ -f "$SSH_KEY" ]] || { echo "Error: Key invalid" >&2; exit 1; }
#chmod 400 "$SSH_KEY" || log "WARN: chmod 400 failed for $SSH_KEY (continuing)"
# Clone Git repository with authentication (not exposing PAT)
clone_repo() {
local repo_url="$1" token="$2" branch="$3"
REPO_NAME=$(basename "$repo_url" .git)
export REPO_NAME
# create a temporary GIT_ASKPASS helper that prints the PAT
TMP_ASKPASS="$(mktemp)"
cat > "$TMP_ASKPASS" <<'EOF'
#!/bin/sh
# Git calls this script to obtain a password. It expects the password on stdout.
echo "$GIT_PASSWORD"
EOF
chmod +x "$TMP_ASKPASS"
# Use GIT_ASKPASS to provide the token securely to git
if [[ -d "$REPO_NAME" ]]; then
log "Repository exists, updating to latest changes on branch '$branch'..."
cd "$REPO_NAME" || { rm -f "$TMP_ASKPASS"; exit 2; }
GIT_PASSWORD="$token" GIT_ASKPASS="$TMP_ASKPASS" git fetch origin || { rm -f "$TMP_ASKPASS"; exit 2; }
GIT_PASSWORD="$token" GIT_ASKPASS="$TMP_ASKPASS" git checkout "$branch" || { rm -f "$TMP_ASKPASS"; exit 2; }
GIT_PASSWORD="$token" GIT_ASKPASS="$TMP_ASKPASS" git pull origin "$branch" || { rm -f "$TMP_ASKPASS"; exit 2; }
else
log "Cloning repository on branch '$branch'..."
GIT_PASSWORD="$token" GIT_ASKPASS="$TMP_ASKPASS" git clone -b "$branch" "$repo_url" || { rm -f "$TMP_ASKPASS"; exit 2; }
cd "$REPO_NAME" || { rm -f "$TMP_ASKPASS"; exit 2; }
fi
rm -f "$TMP_ASKPASS"
log "Successfully cloned/updated repository"
}
# Function to verify Docker configuration
verify_docker_config() {
if [[ -f "Dockerfile" ]] || [[ -f "docker-compose.yml" ]]; then
log "β Docker configuration found"
return 0
else
log "β No Dockerfile or docker-compose.yml found"
exit 3
fi
}
# Function to test SSH connection
test_ssh() {
local user="$1" ip="$2" key="$3"
log "Testing SSH connection to $user@$ip..."
if ssh -i "$key" -o ConnectTimeout=10 -o BatchMode=yes \
"$user@$ip" "echo 'SSH connection successful'" &>/dev/null; then
log "β SSH connection successful"
return 0
else
log "β SSH connection failed"
exit 4
fi
}
# Function to setup remote environment
setup_remote_environment() {
local user="$1" ip="$2" key="$3"
log "Setting up remote environment..."
# Execute commands on remote server
ssh -i "$key" "$user@$ip" 'bash -s' << 'ENDSSH'
set -e
# Update packages
sudo apt-get update -y
# Install Docker
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
fi
# Install Docker Compose
if ! command -v docker-compose &> /dev/null; then
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" \
-o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
fi
# Install Nginx
if ! command -v nginx &> /dev/null; then
sudo apt-get install -y nginx
fi
# Start services
sudo systemctl enable docker nginx
sudo systemctl start docker nginx
# Verify installations
docker --version
docker-compose --version
nginx -v
ENDSSH
log "β Remote environment ready"
}
# Deploy Docker application
deploy_application() {
local user="$1" ip="$2" key="$3" app_port="$4"
log "Deploying application..."
ssh -i "$key" "$user@$ip" bash -s << ENDSSH || { log "β Deploy failed (check connection/logs)"; exit 1; }
set -e
mkdir -p ~/deployment
cd ~/deployment/$REPO_NAME || { echo "Error: Repo dir not found" >&2; exit 1; }
# Stop old containers
docker-compose down 2>/dev/null || docker stop \$(docker ps -q) 2>/dev/null || true
# Remove stopped containers to free names
docker rm \$(docker ps -aq --filter "name=my-app") 2>/dev/null || true
# Build and start
if [[ -f "docker-compose.yml" ]]; then
docker-compose up -d --build --force-recreate
else
docker build -t my-app .
docker run -d -p $app_port:$app_port --name my-app my-app
fi
# Wait for container to be healthy
sleep 5
# Verify container is running
if docker ps | grep -qE "my-app|$REPO_NAME"; then
echo "β Containers running"
else
echo "β No running containers found" >&2
exit 1
fi
ENDSSH
log "β Application deployed successfully"
}
# Function to transfer application files to the remote server
transfer_files() {
local user="$1" ip="$2" key="$3" local_dir="$4"
log "Transferring application files..."
# Ensure the remote deployment directory exists
ssh -i "$key" "$user@$ip" "mkdir -p ~/deployment" || exit 9
# The REPO_NAME is globally available from the clone_repo call
local REPO_TO_COPY="$local_dir/$REPO_NAME"
if [[ ! -d "$REPO_TO_COPY" ]]; then
log "ERROR: Local repository directory '$REPO_TO_COPY' not found."
exit 10
fi
# Optional: Clean up existing remote repo dir to avoid conflicts/permissions issues
#ssh -i "$key" "$user@$ip" "rm -rf ~/deployment/$REPO_NAME" || true
# Transfer with rsync, excluding .git and logs
rsync -avz -e "ssh -i '$key'" --exclude='.git/' --exclude='deploy_*.log' "$REPO_TO_COPY/" "$user@$ip:~/deployment/$REPO_NAME/" || exit 11
log "β Files transferred successfully to ~/deployment/$REPO_NAME"
}
# Configure Nginx as reverse proxy
configure_nginx() {
local user="$1" ip="$2" key="$3" app_port="$4"
log "Configuring Nginx..."
# Create Nginx config
NGINX_CONFIG="
server {
listen 80;
server_name $ip;
location / {
proxy_pass http://localhost:$app_port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
"
# Deploy config
ssh -i "$key" "$user@$ip" bash -s << ENDSSH
echo '$NGINX_CONFIG' | sudo tee /etc/nginx/sites-available/app.conf
sudo ln -sf /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
ENDSSH
log "β Nginx configured successfully"
}
validate_deployment() {
local user="$1" ip="$2" key="$3"
log "Validating deployment..."
# Check container health with fallback if no HEALTHCHECK is defined
CONTAINER_NAME="my-app"
HEALTH_STATUS=$(ssh -i "$key" "$user@$ip" "docker inspect --format '{{.State.Health.Status}}' $CONTAINER_NAME 2>/dev/null || true")
if [[ -n "$HEALTH_STATUS" ]]; then
if [[ "$HEALTH_STATUS" != "healthy" ]]; then
log "β Container $CONTAINER_NAME not healthy (status: $HEALTH_STATUS)"
exit 6
fi
else
# Fallback: ensure container exists and is running
if ! ssh -i "$key" "$user@$ip" "docker ps --filter name=$CONTAINER_NAME --filter status=running --format '{{.Names}}' | grep -q ."; then
log "β Container $CONTAINER_NAME not running"
exit 6
fi
fi
# App endpoint with retries
MAX_RETRIES=3
for i in $(seq 1 $MAX_RETRIES); do
if curl -f -s "http://$ip/" > /dev/null; then
log "β Application /health accessible"
break
fi
[[ $i -eq $MAX_RETRIES ]] && { log "β /health failed after $MAX_RETRIES tries"; exit 8; }
sleep $((i * 2)) # Backoff: 2s, 4s, 6s
done
log "β All validation checks passed"
}
cleanup() {
local user="$1" ip="$2" key="$3"
log "Cleaning up deployment..."
ssh -i "$key" "$user@$ip" bash -s << 'ENDSSH' || exit 15
# Stop and remove containers
docker-compose down -v 2>/dev/null || true
docker stop $(docker ps -aq --filter "name=^my-app") 2>/dev/null || true
docker rm $(docker ps -aq --filter "name=^my-app") 2>/dev/null || true
# Remove Nginx config
sudo rm -f /etc/nginx/sites-enabled/app.conf
sudo rm -f /etc/nginx/sites-available/app.conf
sudo systemctl reload nginx
# Remove deployment files
rm -rf ~/deployment
ENDSSH
log "β Cleanup completed"
}
# Check for cleanup flag
if [[ "${1:-}" == "--cleanup" ]]; then
cleanup "$SSH_USER" "$SERVER_IP" "$SSH_KEY"
exit 0
fi
main() {
local original_dir="$(pwd)" # Capture parent dir before any cd
log "===== Starting Deployment ====="
log "Repository: $GIT_REPO"
log "Branch: $BRANCH"
log "Target Server: $SERVER_IP"
clone_repo "$GIT_REPO" "$PAT" "$BRANCH"
verify_docker_config
cd "$original_dir" || exit 12 # Reset to parent dir for correct transfer path
test_ssh "$SSH_USER" "$SERVER_IP" "$SSH_KEY"
setup_remote_environment "$SSH_USER" "$SERVER_IP" "$SSH_KEY"
transfer_files "$SSH_USER" "$SERVER_IP" "$SSH_KEY" "$(pwd)"
deploy_application "$SSH_USER" "$SERVER_IP" "$SSH_KEY" "$APP_PORT"
configure_nginx "$SSH_USER" "$SERVER_IP" "$SSH_KEY" "$APP_PORT"
validate_deployment "$SSH_USER" "$SERVER_IP" "$SSH_KEY"
log "===== Deployment Completed Successfully ====="
}
main
How It Works: Step-by-Step
From the README.md:
- Inputs: Secure prompts (PAT hidden), with defaults and validation.
- Clone: Uses GIT_ASKPASS to handle PAT without exposure; pulls if exists.
-
Verify: Ensures
Dockerfileis present. - SSH Test: Quick connectivity check with timeout.
- Remote Setup: Installs Docker/Compose/Nginx if missing, starts services.
- Transfer: Rsync for efficient, excluding unnecessary files.
-
Deploy: Idempotent stops/removes old containers, builds/runs new ones (uses
docker build/runsince no compose file). - Nginx: Dynamic config proxies 80 to app port (8080 here).
- Validate: Checks container status, curls the endpoint with retries.
-
Logging/Cleanup: Timestamped logs;
--cleanuptears down everything.
AWS-Specific Adaptations
- EC2 Launch: Used Ubuntu 22.04 AMI, attached SSH key, configured security groups.
- Permissions: SSH user (ubuntu) needs sudo; script handles Docker group add.
- Testing: Ran locally, deployed to EC2βbrowser hit the IP showed the success page with timestamp. Re-runs worked without issues thanks to idempotency.
- Security: No SSL yet (add Certbot for prod); ensure key is 400 perms.
Lessons Learned
- Secure handling of secrets (like PAT) is crucial GIT_ASKPASS was a game-changer.
- Idempotency via stop/rm commands prevents redeploy failures.
- Remote exec with heredocs keeps things clean but watch for quoting.
- Logging + traps make debugging remote issues easier.
Grab the full repo (including README) here: github.com/hyelngtil/hng13-stage1-devops. It's ready to fork and test!
What's your go-to automation trick in Bash? Drop it in the comments! π
Top comments (0)