DevOps is all about automation, reliability, and efficiency. In Stage 1 of my DevOps internship, I built a robust Bash script to automate the deployment of a Dockerized application to a remote Linux server.
Whether you’re a developer, system administrator, or DevOps enthusiast, this guide will walk you step by step through creating a production-ready deployment script that handles everything from cloning a repository to configuring Nginx as a reverse proxy.
Why Automate Deployment?
Manual deployment is error-prone, inconsistent, and time-consuming. Automation provides:
- Consistency: Ensures deployments are identical every time.
- Speed: Reduces human intervention and saves time.
- Reliability: Detects errors early and logs them for troubleshooting.
By the end of this guide, you’ll have a single Bash script (deploy.sh) capable of fully deploying your Dockerized app to a remote server.
Step 1: Collect Parameters from User Input
The first step is interactively collecting essential details:
- Git repository URL
- Personal Access Token (PAT)
- Branch name (defaults to main)
- SSH credentials (username, server IP, key path)
- Application port
We also validate the inputs to prevent script failure later. Example snippet:
# Git Repository URL
while [[ -z "${GIT_REPO_URL:-}" ]]; do
read -rp "Enter the git repository URL: " GIT_REPO_URL
[[ -z "$GIT_REPO_URL" ]] && log_error "Repository URL cannot be empty."
done
# Personal Access Token (PAT) - hidden input
while [[ -z "${GIT_PAT:-}" ]]; do
read -rsp "Enter your Git Personal Access Token (PAT): " GIT_PAT
echo ""
[[ -z "$GIT_PAT" ]] && log_error "Personal Access Token cannot be empty."
done
# Branch name (default = main)
read -rp "Enter branch name [default: main]: " GIT_BRANCH
GIT_BRANCH=${GIT_BRANCH:-main}
#SSH username
while [[ -z "${SSH_USER:-}" ]]; do
read -rp "Enter remote server SSH username: " SSH_USER
[[ -z "$SSH_USER" ]] && log_error "SSH username cannot be empty."
done
#Server IP address
while [[ -z "${SERVER_IP:-}" ]]; do
read -rp "Enter remote server IP address: " SERVER_IP
if [[ ! "$SERVER_IP" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
log_error "Invalid IP format. Please enter a valid IPV4 address."
SERVER_IP=""
fi
done
# SSH key path
while [[ -z "${SSH_KEY_PATH:-}" ]]; do
read -rp "Enter path to SSH private key: " SSH_KEY_PATH
if [[ ! -f "$SSH_KEY_PATH" ]]; then
log_error "SSH key file not found at $SSH_KEY_PATH"
SSH_KEY_PATH=""
fi
done
# Application port (internal container port)
while [[ -z "${APP_PORT:-}" ]]; do
read -rp "Enter internal application (container) port (e.g., 8080): " APP_PORT
if ! [[ "$APP_PORT" =~ ^[0-9]+$ ]]; then
log_error "Invalid port. Please enter a numeric value."
APP_PORT=""
fi
done
Tip: Always validate user input using conditionals to ensure URLs are valid and files exist.
Step 2: Clone or Pull the Repository
Next, we authenticate using the PAT and clone the repo, or pull the latest changes if it already exists:
REPO_NAME=$(basename -s .git "$GIT_REPO_URL")
WORK_DIR="$HOME/deployment/$REPO_NAME"
# Create deployment directory if not exists
mkdir -p "$(dirname "$WORK_DIR")"
# Authenticated URL (PAT safely embedded)
GIT_USERNAME=$(echo "$GIT_REPO_URL" | awk -F[/:] '{print $(NF-1)}')
AUTH_REPO_URL=$(echo "$GIT_REPO_URL" | sed "s#https://#https://$GIT_USERNAME:$GIT_PAT@#")
if [[ -d "$WORK_DIR/.git" ]]; then
log_info " Repository already exists. Pulling latest changes..."
cd "$WORK_DIR"
git reset --hard
git clean -fd
git fetch origin "$GIT_BRANCH"
git checkout "$GIT_BRANCH"
git pull origin "$GIT_BRANCH" || {
log_error " Failed to pull latest changes from $GIT_BRANCH"
exit $EXIT_UNKNOWN
}
else
log_info " Cloning repository into $WORK_DIR..."
git clone --branch "$GIT_BRANCH" "$AUTH_REPO_URL" "$WORK_DIR" || {
log_error " Failed to clone repository. Please check your URL or PAT"
exit $EXIT_UNKNOWN
}
cd "$WORK_DIR"
fi
log_success " Repository is ready at: $WORK_DIR"
This step ensures your local project folder is always up-to-date with the remote repo.
Step 3: Navigate into the Project Directory
After cloning, we enter the project folder and validate the Docker setup:
# Check for Docker configuration files
if [[ -f "Dockerfile" ]]; then
log_success " Found Dockerfile - ready for Docker build."
elif [[ -f "compose.yaml" || -f "compose.yml" || -f "docker-compose.yaml" || -f "docker-compose.yml" ]]; then
log_success " Found docker-compose.yml - ready for multi-service deployment"
else
log_error " No Dockerfile or docker-compose.yml found. Cannot continue deployment."
exit $EXIT_UNKNOWN
fi
Step 4: SSH into the Remote Server
Using SSH, we perform connectivity checks and prepare to execute remote commands:
# Validate SSH key exists
if [ ! -f "$SSH_KEY_PATH" ]; then
log_error "SSH key not found at: $SSH_KEY_PATH"
exit $EXIT_SSH_FAIL
fi
# Test SSH connection (non-interactive)
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no -o BatchMode=yes "$SSH_USER@$SERVER_IP" "echo 'SSH connection successful!'" >/dev/null 2>&1
if [ $? -ne 0 ]; then
log_error "Unable to connect to remote server via SSH. Please verify credentials, key permissions, and IP address."
exit $EXIT_SSH_FAIL
else
log_success "SSH connection verified successfully."
fi
Step 5: Prepare the Remote Environment
On the remote server, we need to ensure all dependencies exist:
- Update packages
- Install Docker, Docker Compose, and Nginx
- Add user to Docker group
- Enable and start services
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no "$SSH_USER@$SERVER_IP" bash <<EOF
set -e
echo "Updating system packages..."
sudo apt-get update -y && sudo apt-get upgrade -y
echo "Installing required packages (curl, ca-certificates, gnupg, lsb-release)..."
sudo apt-get install -y ca-certificates curl gnupg lsb-release
# --- Install Docker if not installed ---
if ! command -v docker &>/dev/null; then
echo "Docker not found. Installing Docker..."
curl -fsSL https://get.docker.com | sudo bash
else
echo "Docker already installed."
fi
# --- Install Docker Compose if not installed ---
if ! command -v docker-compose &>/dev/null; then
echo "Installing Docker Compose..."
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
else
echo "Docker Compose already installed."
fi
# --- Install nginx if not installed ---
if ! command -v nginx &>/dev/null; then
echo "Installing nginx..."
sudo apt-get install -y nginx
else
echo "nginx already installed."
fi
# --- Add SSH user to docker group ---
if ! groups $SSH_USER | grep -q docker; then
echo "Adding user '$SSH_USER' to docker group..."
sudo usermod -aG docker $SSH_USER
echo "You may need to log out and back in for this to take effect."
else
echo "User '$SSH_USER' already in docker group."
fi
# --- Enable and start services ---
sudo systemctl enable docker
sudo systemctl start docker
sudo systemctl enable nginx
sudo systemctl start nginx
# --- Confirm installation versions ---
echo "Confirming versions..."
docker --version
docker-compose --version
nginx -v
echo "Remote environment setup complete."
EOF
Best practice: Always confirm installation versions and service status:
docker --version
docker-compose --version
nginx -v
Step 6: Deploy the Dockerized Application
We transfer project files to the server and build/run Docker containers:
rsync -avz -e "ssh -i $SSH_KEY_PATH -o StrictHostKeyChecking=no" \
--exclude '.git' --exclude 'node_modules' --exclude '.env' \
"$CLONE_DIR/" "$SSH_USER@$SERVER_IP:$REMOTE_APP_DIR" >/dev/null 2>&1
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no "$SSH_USER@$SERVER_IP" bash <<EOF
set -e
cd $REMOTE_APP_DIR
# --- Detect Docker configuration ---
if [[ -f "docker-compose.yml" ]]; then
echo "docker-compose.yml found. Starting with Docker Compose..."
sudo docker-compose pull
sudo docker-compose build
sudo docker-compose up -d
elif [[ -f "Dockerfile" ]]; then
echo "Dockerfile found. Building and running manually..."
APP_NAME=\$(basename \$(pwd))
sudo docker build -t \$APP_NAME .
sudo docker run -d -p $APP_PORT:$APP_PORT --name \$APP_NAME \$APP_NAME
else
echo "No Dockerfile or docker-compose.yml found in project directory."
exit 1
fi
# --- Step 3: Validate container health ---
echo "Checking running containers..."
sudo docker ps
# --- Step 4: Verify app is accessible on the specified port ---
echo "Validating application accessibility on port $APP_PORT..."
sleep 5
if curl -s "http://localhost:$APP_PORT" >/dev/null; then
echo "Application is running and accessible on port $APP_PORT!"
else
echo "Application did not respond on port $APP_PORT. Check container logs."
sudo docker logs \$(sudo docker ps -q --latest)
fi
EOF
This ensures the container is up, healthy, and accessible on the specified port.
Step 7: Configure Nginx as a Reverse Proxy
Nginx forwards traffic from port 80 to your Docker container:
DOMAIN_NAME="${DOMAIN_NAME:-example.com}"
# Create a temporary nginx config locally with correct escaping for nginx $-vars
TMP_NGINX_CONF="$(mktemp /tmp/app_proxy.XXXXXX.conf)"
cat > "$TMP_NGINX_CONF" <<EOF
server {
listen 80;
server_name ${DOMAIN_NAME};
location / {
proxy_pass http://127.0.0.1:${APP_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
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;
proxy_cache_bypass \$http_upgrade;
}
access_log /var/log/nginx/app_access.log;
error_log /var/log/nginx/app_error.log;
}
# SSL placeholder (Certbot or self-signed)
# server {
# listen 443 ssl;
# server_name ${DOMAIN_NAME};
# ssl_certificate /etc/ssl/certs/app.crt;
# ssl_certificate_key /etc/ssl/private/app.key;
# location / {
# proxy_pass http://127.0.0.1:${APP_PORT};
# proxy_set_header Host \$host;
# }
# }
EOF
NGINX_CONFIG_PATH="/etc/nginx/sites-available/app_proxy"
NGINX_ENABLED_PATH="/etc/nginx/sites-enabled/app_proxy"
# Copy config to remote /tmp then move with sudo to proper location to avoid permission issues
scp -i "$SSH_KEY_PATH" "$TMP_NGINX_CONF" "$SSH_USER@$SERVER_IP:/tmp/app_proxy.conf" >/dev/null 2>&1 || {
log_error "Failed to upload nginx config to remote host."
rm -f "$TMP_NGINX_CONF"
exit $EXIT_NGINX_FAIL
}
rm -f "$TMP_NGINX_CONF"
# Apply config remotely: install nginx if missing, remove default, move config, enable, test, reload
ssh -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no "$SSH_USER@$SERVER_IP" bash -s <<'REMOTE_EOF'
set -e
NGINX_CONFIG_PATH="/etc/nginx/sites-available/app_proxy"
NGINX_ENABLED_PATH="/etc/nginx/sites-enabled/app_proxy"
# Install nginx if missing
if ! command -v nginx &>/dev/null; then
echo "Installing nginx..."
sudo apt-get update -y
sudo apt-get install -y nginx
fi
# Remove default site if present
if [ -f /etc/nginx/sites-enabled/default ]; then
echo "Removing default nginx site..."
sudo rm -f /etc/nginx/sites-enabled/default
fi
# Move uploaded config into place
sudo mv /tmp/app_proxy.conf "$NGINX_CONFIG_PATH"
sudo chown root:root "$NGINX_CONFIG_PATH"
sudo chmod 644 "$NGINX_CONFIG_PATH"
# Enable site (idempotent)
sudo ln -sf "$NGINX_CONFIG_PATH" "$NGINX_ENABLED_PATH"
# Test and reload
if sudo nginx -t; then
echo "nginx config OK - reloading"
sudo systemctl reload nginx
else
echo "nginx config test FAILED"
sudo nginx -t || true
exit 1
fi
echo "Nginx reverse proxy configured"
REMOTE_EOF
Step 8: Validate Deployment
Check that everything is running and accessible:
ssh -i $SSH_KEY $REMOTE_USER@$REMOTE_IP "docker ps"
curl http://$REMOTE_IP
All services should respond correctly.
Step 9: Logging and Error Handling
Logging is critical for troubleshooting. Our script:
- Saves logs to deploy_YYYYMMDD.log
- Uses trap to catch errors
- Exits with meaningful codes
LOG_FILE="$LOG_DIR/deploy_$(date +'%Y%m%d_%H%M%S').log"
# --- Logging functions ---
log_info() { echo -e "\033[1;34m[INFO]\033[0m $1" | tee -a "$LOG_FILE"; }
log_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1" | tee -a "$LOG_FILE"; }
log_warn() { echo -e "\033[1;33m[WARN]\033[0m $1" | tee -a "$LOG_FILE"; }
log_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" | tee -a "$LOG_FILE"; }
# --- Error trapping ---
trap 'handle_error $? $LINENO' ERR
handle_error() {
local exit_code=$1
local line_no=$2
log_error " Script failed at line $line_no (exit code: $exit_code)"
log_error " Deployment aborted. Check $LOG_FILE for details."
exit "$exit_code"
}
Step 10: Idempotency and Cleanup
The script is safe to rerun. Previous containers are stopped/removed before redeployment. Optional --cleanup removes all deployed resources.
Conclusion
By building this Automated Deployment Bash Script, I’ve learned how to:
- Interact with users in Bash scripts
- Automate Docker deployment
- Configure remote servers and Nginx
- Implement robust logging, error handling, and idempotency
This project mirrors real-world DevOps workflows and is a great starting point for anyone looking to build production-grade deployment scripts.
Complete repo link
Top comments (0)