Docker Swarm: The Simple, Scalable Alternative to Kubernetes (With a One-Command Deployment Guide)
Docker Swarm is often overlooked in favour of Kubernetes. Here’s what makes it stand out: Swarm is a lot easier to set
up and manage, especially for small to medium-sized teams. You don't need to be a container orchestration wizard to get
it running smoothly. While Kubernetes offers more flexibility and features, Swarm wins hands down in simplicity and
speed. No steep learning curves. It just works. I will walk you through the configurations and script files in the
context of a laravel and nuxt stack along with auxiliary services (you can see the complete examples
here). Once we're done you should be able to run something akin to
SERVER=ip_address SSH_USER=my-user SSH_KEY_PATH=~/.ssh/my-app ./docker/deploy.sh
. Alternatively, the logic can be
adapted to be part of a CI/CD pipeline. First let's start with deploy.sh
; This is your all-in-one tool to deploy
Docker images to a Swarm cluster—automatically. With a single command, you'll build and push images, create secrets,
initialise your Swarm, and deploy your stack. It’s a powerful shortcut that saves you hours of manual configuration.
Step 1: Housekeeping
Before running anything, the script ensures all necessary variables are set. This prevents common deployment failures caused by missing credentials or misconfigurations.
# List of required environment variables
REQUIRED_VARS="SSH_KEY_PATH SSH_USER SERVER"
# Check if each required variable is set
for VAR in $REQUIRED_VARS; do
eval "VALUE=\$$VAR"
if [ -z "$VALUE" ]; then
echo "Error: $VAR is not set."
exit 1
fi
done
If any required variable is missing, the script stops immediately. This saves you from frustrating debugging sessions
later on. We could also do further niceties like expanding the path if it starts with ~
and check existence.
# Expand SSH_KEY_PATH if it starts with ~
if [ "${SSH_KEY_PATH#\~}" != "$SSH_KEY_PATH" ]; then
SSH_KEY_PATH="$HOME${SSH_KEY_PATH#\~}"
fi
# Check if SSH key file exists
if [ ! -f "$SSH_KEY_PATH" ]; then
echo "Error: SSH key file not found at $SSH_KEY_PATH."
exit 1
fi
Step 2: Building and Pushing the Images
Next, the script gathers the stack name, registry, and deployment stage. It then builds and pushes the images to the registry. Nothing to see here really should be straight forward.
# --- Build image locally and push to registry ---
STACK_NAME=${1:-"my-app"}
REGISTRY=your-registry
# STAGE is either production or yeah... that's it for now. (docker-compose.${STAGE}.yml and .env.${STAGE} must be defined)
STAGE=${2:-"production"}
# if .env.${STAGE} or docker-compose.${STAGE} file doesn't exist, exit
if [ ! -f ".env.${STAGE}" ] || [ ! -f "docker-compose.${STAGE}.yml" ]; then
echo "Error: .env.${STAGE} or docker-compose.${STAGE}.yml file doesn't exist."
exit 1
fi
export LARAVEL_IMAGE=${REGISTRY}/my-app-laravel-api
export NUXT_IMAGE=${REGISTRY}/my-app-nuxt-web
NOW=$(date +%Y-%m-%d-%H-%M-%S)
GIT_SHA=$(git rev-parse --short HEAD)
echo "Building images..."
docker buildx build --platform linux/amd64 \
--target="${STAGE}" \
--build-arg NOW="${NOW}" \
--build-arg GIT_SHA="${GIT_SHA}" \
--tag ${NUXT_IMAGE}:latest \
--file nuxt.Dockerfile .
docker buildx build --platform linux/amd64 \
--target="${STAGE}" \
--build-arg NOW="${NOW}" \
--build-arg GIT_SHA="${GIT_SHA}" \
--tag ${LARAVEL_IMAGE}:latest \
--file laravel.Dockerfile .
echo "Pushing images..."
docker push ${LARAVEL_IMAGE}:latest
docker push ${NUXT_IMAGE}:latest
This step ensures that our Laravel API and Nuxt frontend images are up to date and ready for deployment.
Step 3: Managing Secrets Securely
Instead of having to manage secrets with .env files manually on multiple nodes, docker swarm allows us to create secrets, which in turn will be used as a stand in for .env files. So, we'll make a new name for our secret (sort of versioning it) to differentiate from the old ones. Once we're acting on our manager node (see full example) then we'll create the secret from the present .env file respective to the environment (/stage). Once our deployment is complete, we remove all dangling secrets for the sake of tidiness.
export DOTENV_NAME="${STACK_NAME}-dotenv-${NOW}"
docker secret create "${DOTENV_NAME}" - < ".env.${STAGE}"
echo "Removing old secrets..."
# remove all docker secrets that isn't DOTENV_NAME
docker secret ls --format "{{.ID}} {{.Name}}" | grep -v "${DOTENV_NAME}" | cut -d " " -f 1 | xargs docker secret rm
This way, our .env file is distributed all nodes and never gets exposed in transit, keeping sensitive data safe.
Step 4: Setting up the Swarm
Before deploying, we have to ensure that our Swarm is up and running. This means the overlay network is created, controlling node is there, and we have the necessary volumes set up.
First, quickly make sure that ssh is set up because we'll be running docker commands on the server.
# add ssh key to ssh-agent from the private key path while removing new lines
tr -d '\r' < "$SSH_KEY_PATH" | ssh-add - > /dev/null
# add server to known hosts if doesn't already exist (has the side effect of sorting the file)
ssh-keyscan "${SERVER}" >> ~/.ssh/known_hosts && sort -u ~/.ssh/known_hosts -o ~/.ssh/known_hosts
# I command all docker commands shall run on the server henceforth https://docs.docker.com/reference/cli/docker/#environment-variables
export DOCKER_HOST=ssh://"${SSH_USER}"@"${SERVER}"
Now that we're acting on the server, we can set up the Swarm if it's not already set up. We'll also create the overlay network for cross-node communication and the volume for caddy data.
# if not a docker swarm manager, make it so
if [ "$(docker info --format '{{.Swarm.LocalNodeState}}')" != "active" ]; then
echo "Node is not part of a Swarm. Initializing Swarm..."
docker swarm init
fi
# create a network for the proxy if doesn't exist
if [ -z "$(docker network ls --filter scope=swarm --filter name=proxy --quiet)" ]; then
echo "Creating proxy network..."
docker network create --scope swarm --driver=overlay proxy
fi
# create external volume caddy_data if doesn't exist
if [ -z "$(docker volume ls --filter name=caddy_data --quiet)" ]; then
echo "Creating caddy_data volume..."
docker volume create --name=caddy_data
fi
Step 5: Deploying the Stack
Now comes the deployment. We're going to use sudo-bmitch's docker-stack-wait
command so our scripts actually waits
(and potentially fails) along with our deployment. The script uses docker stack deploy
command to roll out the
application.
echo "Downloading docker-stack-wait script..."
# download docker-stack-wait script
curl -sSL -o /usr/local/bin/docker-stack-wait https://raw.githubusercontent.com/sudo-bmitch/docker-stack-wait/main/docker-stack-wait.sh
chmod +x /usr/local/bin/docker-stack-wait
echo "Deploying stack ${STACK_NAME}..."
# deploy the stack as detached
# using these compose files,
# prune any unused services
# and forward the registry login details to the nodes
# (the weird order of compose files is because of https://github.com/docker/cli/issues/2407)
docker stack deploy \
--detach \
--compose-file docker-compose."${STAGE}".yml --compose-file docker-compose.yml \
--prune \
--with-registry-auth \
"${STACK_NAME}"
echo "Waiting for the stack to be deployed..."
docker-stack-wait "${STACK_NAME}"
Swarm handles the deployment efficiently, rolling out updates with zero downtime and only switching traffic to new container if deployment is successful. Note: There's a caveat here, if let's say the web service successfully deploys but not the api, your web service will be talking to an old version of your api and potentially sending incongruent requests
🚨🚨 Important: Here we're blindly trusting what we're downloading script is what we expect. We really should add a
checksum check to ensure the script hasn't been tampered with (same with wait-for-it
and set-id
in the
laravel.Dockerfile
). If no checksum given we can generate our own as we don't expect these scripts to change often.
How It All Comes Together
The following files define the services and infrastructure. These configurations ensure that our stack is scalable and production-ready.
Final Thoughts
Armed with this information, while you may need to adjust to your use case, you should be able to deploy your stack with ease. By leveraging Docker Swarm’s streamlined orchestration, you get a fast, efficient way to deploy your applications without all the overhead of Kubernetes.
✅ Simple setup – No complex configurations, just a straightforward deployment.
✅ Secure secrets management – Keeps credentials safe.
✅ Effortless scaling – Easily scale services when needed.
✅ No need for third-party tools – Everything is built into Docker.
Couple with ansible for provisioning servers and you have a complete deployment pipeline. So why not give it a try? Your next deployment could be just a single script away.