58.12 Project 28: Deploy to EC2 from your laptop with docker context over SSH
Why: the problem
Suppose we are a two-person startup in Bengaluru. Our app runs on one EC2 server. Every release, someone does this: SSH into the server, git pull, build, restart, and hope. It is slow, and the server needs our source code and build tools. When it breaks at night, nobody remembers which commands were typed. Docker has a cleaner way. The docker command on the laptop can talk to the Docker engine on another machine over SSH. This is a docker context. We switch the context to prod, and docker compose up runs on the EC2, using compose files from our laptop. No copying folders, no open Docker port, only SSH. We do it in two styles. First, build on the server through the context. Then the better way: build and push to a registry from the laptop or CI, and the server only pulls tagged images. We add a deploy script with a health check and an instant rollback.
What: every term in plain English
- Docker context — A saved target for the docker command: local engine, or a remote engine over SSH.
- DOCKER_HOST — An env var that also points docker at a remote engine, for example ssh://ubuntu@server.
- ssh-agent — Keeps your SSH key unlocked in memory so docker can use it without asking every time.
- Remote build — docker build with a remote context sends the build context over SSH and builds on the server.
- Image tag — A version label like 1.4.2 or the git commit id. We deploy tags, never latest.
- Rollback — Running the previous tag again, in seconds.
- docker group — Members can use Docker without sudo. It is the same as root on that host, so add only deploy users.
How: step by step
- Prepare the EC2. Install Docker, add the ubuntu user to the docker group, open 22 to your IP only.
- Set up SSH. Host entry in ~/.ssh/config and ssh-agent with the key.
- Create the context. docker context create prod --docker host=ssh://prod.
- Deploy by building remotely. docker --context prod compose up -d --build.
- Deploy by pulling tags. Push images from the laptop, run a deploy script with health check.
- Roll back and clean up. Deploy the old tag; remove the context and terminate the EC2.
Architecture
Folder layout
startup/
server/setup-ec2.sh
laptop/ssh-config.txt laptop/context.sh
laptop/release.sh laptop/deploy.sh laptop/cleanup.sh
app/compose.yaml app/compose.prod.yaml app/.env.example
app/web/ (Dockerfile and code from Project 22)
Example 1: Prepare the server and the SSH connection, then create the context
Goal. Prepare the server and the SSH connection, then create the context.
server/setup-ec2.sh
# run once on the EC2 (Ubuntu 24.04) over SSH
set -e
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker ubuntu
sudo systemctl enable --now docker
echo "log out and in again so the docker group applies"
laptop/ssh-config.txt
# add to ~/.ssh/config on the laptop
Host prod
HostName 203.0.113.10
User ubuntu
IdentityFile ~/.ssh/startup-prod.pem
ServerAliveInterval 30
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
laptop/context.sh
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/startup-prod.pem
ssh prod docker version --format '{{.Server.Version}}'
docker context create prod --description "EC2 Mumbai" --docker "host=ssh://prod"
docker context ls
docker --context prod ps
What each line does
download.docker.com— The official Docker apt repository. Docker docs show the same steps.usermod -aG docker ubuntu— ubuntu can run docker without sudo. This is root-level power, so only trusted users.Host prod— A short name. docker uses your normal ssh, so this config applies.ControlMaster / ControlPersist— Keeps one SSH connection open and reuses it. Remote docker commands become much faster.ssh-add— The key is loaded once. docker cannot type a passphrase for you.No port 2375— We never open the Docker TCP port to the internet. SSH is the only door, and only from your IP.
Green output
$ bash laptop/context.sh
Agent pid 41822
Identity added: /home/asha/.ssh/startup-prod.pem
27.3.1
prod
NAME DESCRIPTION DOCKER ENDPOINT
default * Current DOCKER_HOST based configuration unix:///var/run/docker.sock
prod EC2 Mumbai ssh://prod
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
Expected result. The laptop can now control the EC2 Docker engine. The server has no containers yet.
Example 2: Style 1: build and run on the server from the laptop with the same compose file
Goal. Style 1: build and run on the server from the laptop with the same compose file.
app/compose.yaml
services:
web:
build: ./web
image: notes-app:${TAG:-dev}
environment:
DATABASE_URL: postgresql://notes:${DB_PASSWORD}@db:5432/notes
depends_on:
db: { condition: service_healthy }
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
retries: 3
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: notes
volumes: [db-data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD", "pg_isready", "-U", "notes"], interval: 5s, retries: 10 }
restart: unless-stopped
proxy:
image: nginx:1.27-alpine
ports: ["80:80"]
configs:
- source: nginx
target: /etc/nginx/conf.d/default.conf
depends_on: [web]
restart: unless-stopped
configs:
nginx:
content: |
server {
listen 80;
location / { proxy_pass http://web:3000; proxy_set_header Host $$host; }
}
volumes:
db-data:
app/.env.example
DB_PASSWORD=change-me-db
TAG=dev
What each line does
configs with content— The nginx file is written inside compose.yaml. With a remote context, bind mounts like ./nginx.conf would look for the file on the server, not the laptop. Inline configs avoid that.$$host— Double dollar so Compose does not treat $host as its own variable.build: ./web— With --context prod, the web folder is sent over SSH and built on the EC2..env stays on the laptop— Compose reads .env locally and sends the final values. The server never needs the file.Named volume db-data— Lives on the server. It is not affected when we switch contexts.docker context use prod— Makes prod the default. Risky: you may forget. We prefer --context prod on each command.
Green output
$ cd app && cp .env.example .env
$ docker --context prod compose up -d --build
=> [web internal] load build context 0.2s
=> transferring context: 48.31kB
Container app-db-1 Healthy
Container app-web-1 Started
Container app-proxy-1 Started
$ docker --context prod compose ps --format '{{.Name}} {{.Status}}'
app-db-1 Up 40 seconds (healthy)
app-proxy-1 Up 30 seconds
app-web-1 Up 31 seconds (healthy)
$ curl -s http://203.0.113.10/healthz
ok
Expected result. The app runs on EC2. We typed every command on the laptop.
Example 3: Style 2 (better): push tagged images, deploy with a health check, and roll back
Goal. Style 2 (better): push tagged images, deploy with a health check, and roll back.
laptop/release.sh
set -e
REG=123456789012.dkr.ecr.ap-south-1.amazonaws.com
TAG=$(git rev-parse --short HEAD)
aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin "$REG"
docker build --platform linux/amd64 -t "$REG/notes-app:$TAG" app/web
docker push "$REG/notes-app:$TAG"
echo "$TAG" | tee .last-built-tag
app/compose.prod.yaml
services:
web:
image: 123456789012.dkr.ecr.ap-south-1.amazonaws.com/notes-app:${TAG}
build: !reset null
pull_policy: always
laptop/deploy.sh
set -e
TAG=${1:?usage: deploy.sh TAG}
C="docker --context prod compose -f app/compose.yaml -f app/compose.prod.yaml --env-file app/.env"
PREV=$(docker --context prod inspect app-web-1 --format '{{.Config.Image}}' 2>/dev/null | cut -d: -f2 || echo none)
echo "deploying $TAG (previous $PREV)"
TAG=$TAG $C up -d --no-deps web
for i in $(seq 1 12); do
S=$(docker --context prod inspect app-web-1 --format '{{.State.Health.Status}}')
echo "health: $S"
[ "$S" = "healthy" ] && { echo "$TAG" | tee .deployed-tag; exit 0; }
sleep 5
done
echo "NOT healthy, rolling back to $PREV"
TAG=$PREV $C up -d --no-deps web
exit 1
laptop/cleanup.sh
docker --context prod compose -f app/compose.yaml down -v
docker --context prod image prune -af
docker context rm prod
echo "now terminate the EC2 instance in the console or with aws ec2 terminate-instances"
What each line does
Tag = git commit— Every image matches a commit. We always know what is running.--platform linux/amd64— Laptops with Apple chips build ARM images by default. The EC2 is x86, so we say amd64. Or use a t4g ARM instance.!reset null— In the prod override, the build section is removed, so the server only pulls.Server needs ECR access— Attach an IAM role with AmazonEC2ContainerRegistryReadOnly to the EC2, and install the ECR credential helper there. No keys on the server.--no-deps web— Only web is replaced. The database keeps running.Health loop and rollback— If web is not healthy within 60 seconds, the script redeploys the previous tag automatically.Same script in CI— GitHub Actions or Jenkins can run release.sh and deploy.sh with an SSH key stored as a secret.
Green output
$ bash laptop/release.sh
Login Succeeded
=> pushing 123456789012.dkr.ecr.ap-south-1.amazonaws.com/notes-app:9f3c2ab
9f3c2ab
$ bash laptop/deploy.sh 9f3c2ab
deploying 9f3c2ab (previous dev)
Container app-web-1 Recreated
health: starting
health: healthy
9f3c2ab
$ bash laptop/deploy.sh broken01
deploying broken01 (previous 9f3c2ab)
health: starting
health: unhealthy
...
NOT healthy, rolling back to 9f3c2ab
Expected result. Good tags go live in seconds, and a bad tag is rolled back automatically.
Every command, step by step (plain docker and Compose)
-
Launch EC2
Ubuntu 24.04, t3.small, security group: 22 from your IP, 80 from anywhereWhat it does: The server for this project.
Instance running, public IP 203.0.113.10 -
Install Docker on it
scp server/setup-ec2.sh prod: && ssh prod bash setup-ec2.shWhat it does: Official Docker packages.
log out and in again -
Load your key
eval "$(ssh-agent -s)" && ssh-add ~/.ssh/startup-prod.pemWhat it does: docker can now use SSH quietly.
Identity added -
Test SSH docker
ssh prod docker psWhat it does: Works without sudo.
CONTAINER ID IMAGE ... -
Create context
docker context create prod --docker host=ssh://prodWhat it does: Saves the target.
prod -
List contexts
docker context lsWhat it does: Star shows the current one.
default * -
Deploy style 1
docker --context prod compose up -d --buildWhat it does: Builds and runs on EC2.
Container app-web-1 Started -
Check from outside
curl -s http://203.0.113.10/healthzWhat it does: The app answers.
ok -
Read remote logs
docker --context prod compose logs --tail 5 webWhat it does: Logs over SSH.
web-1 | listening on 3000 -
Release style 2
bash laptop/release.shWhat it does: Builds and pushes a tagged image.
9f3c2ab -
Deploy a tag
bash laptop/deploy.sh 9f3c2abWhat it does: Health check then success.
health: healthy -
Clean up
bash laptop/cleanup.shWhat it does: Removes stack, images and context; then terminate the EC2.
now terminate the EC2 instance
When it is red 1
error during connect: ... ssh: connect to host 203.0.113.10 port 22: Connection timed out
Cause. The security group does not allow SSH from your current IP.
Fix, step by step
- Edit inbound rule 22 to your current public IP.
- Check with ssh prod true first.
When it is red 2
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
Cause. The ubuntu user is not in the docker group yet, or did not log in again.
Fix, step by step
- Run sudo usermod -aG docker ubuntu, then reconnect.
- Kill the old ControlMaster: ssh -O exit prod.
When it is red 3
Load key "/home/asha/.ssh/startup-prod.pem": bad permissions
Cause. The key file is readable by others. SSH refuses it.
Fix, step by step
- Make the key private to your user, for example with the file permission setting 600 in your file manager or with your usual tool.
- On Windows, remove other users from the file's security tab.
When it is red 4
exec format error
Cause. The image was built for ARM on an Apple laptop and runs on an x86 EC2.
Fix, step by step
- Build with --platform linux/amd64, or use docker buildx for both.
When it is red 5
Error response from daemon: pull access denied for 123456789012.dkr.ecr...
Cause. The server cannot log in to ECR.
Fix, step by step
- Attach the ECR read-only role to the EC2.
- Install amazon-ecr-credential-helper on the server and set credsStore ecr-login.
When it is red 6
open /home/ubuntu/app/nginx.conf: no such file or directory
Cause. A bind mount path is resolved on the server, but the file is on the laptop.
Fix, step by step
- Use inline configs, or copy the file to the server first, or bake it into the image.
Practice tasks
Easy Task 1: Context
Why: Create.
What to do: Create the prod context and list it.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker context create prod --docker host=ssh://prodEasy Task 2: Remote ps
Why: Use.
What to do: Run docker ps on prod without logging in.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker --context prod psEasy Task 3: Deploy
Why: Compose.
What to do: Deploy style 1 and open the public IP.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
docker --context prod compose up -d --buildMedium Task 4: DOCKER_HOST
Why: Env.
What to do: Do the same with DOCKER_HOST=ssh://prod instead of a context.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
DOCKER_HOST=ssh://prod docker psMedium Task 5: Bind mount trap
Why: Paths.
What to do: Change the nginx config to a bind mount and see the error.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
./nginx.conf:/etc/nginx/conf.d/default.confMedium Task 6: Release
Why: ECR.
What to do: Push a commit tag to ECR.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash laptop/release.shMedium Task 7: Health deploy
Why: Script.
What to do: Deploy the tag and watch the health loop.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash laptop/deploy.sh TAGHard Task 8: Rollback
Why: Script.
What to do: Deploy a broken tag and confirm auto rollback.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash laptop/deploy.sh broken01Hard Task 9: CI
Why: Actions.
What to do: Run release and deploy from a CI job with approval.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
environment: productionEasy Task 10: Clean
Why: Habit.
What to do: Remove the stack and context and terminate the EC2.
- Use your laptop with Docker and one Ubuntu EC2 (t3.small) you can SSH into.
- Use the 3-tier app from Project 16 or the notes app from Project 22.
- Run the steps and compare with the solution. Always run the cleanup step.
Solution
bash laptop/cleanup.shHomework: try at home
Move the deploy into GitHub Actions: on a push to main, build and push the tag to ECR, then run deploy.sh with DOCKER_HOST=ssh://prod using an SSH key from repository secrets and a known_hosts entry. Add a manual approval step with an environment named production.
Recap
- A docker context lets the laptop or CI control a remote Docker engine over SSH.
- Never open the Docker TCP port; SSH from your IP is enough.
- Bind mounts resolve on the server; use inline configs or bake files into images.
- Deploy tagged images from a registry; the server only pulls.
- A health-check loop with automatic rollback makes deploys safe.
- Clean up contexts, images and the EC2 after practice.
FAQ
Q. Is this good for production?
For one or two servers, yes. For many servers use ECS (Project 29), Swarm (Project 30) or Kubernetes.
Q. Can I use Windows?
Yes. Docker Desktop and the Windows OpenSSH client work with ssh:// contexts.
Q. Is docker context the same as kubectl context?
Same idea, different tool. One points docker, the other points kubectl.
Q. How do I see which context I am on?
docker context ls shows a star, and docker context show prints the name.
Q. Can two people deploy at once?
They can clash. Deploy from CI only, with one job at a time.
Q. Where do secrets live on the server?
Nowhere in files. Compose sends the values from the laptop or CI. For more safety, read them from SSM Parameter Store at start, as in Project 17.
Q. Is remote build slow?
The build context goes over SSH every time. A good .dockerignore and the registry style make it fast.
Q. What about zero downtime?
With one web container there is a few seconds gap. Run two copies behind nginx (Project 22) and replace one at a time.
Interview questions
-
How do you deploy containers to a single VM without Kubernetes?
Model answer: Push images to a registry and run compose on the VM, often through a docker context or CI over SSH.
-
What is a docker context?
Model answer: A saved endpoint configuration for the Docker CLI.
-
How do you roll back a container deploy?
Model answer: Run the previous image tag; keep tags immutable and never rely on latest.
-
How do you give an EC2 access to ECR without keys?
Model answer: An IAM instance role with ECR read permissions and the credential helper.
-
Why is access to the Docker socket sensitive?
Model answer: It allows starting privileged containers, which equals root on the host.
Quick quiz
Q1. What is a docker context?
A named target for the docker CLI, such as a remote engine over SSH.
Q2. Why SSH instead of opening port 2375?
The Docker API is root access. SSH adds keys and encryption and no new open port.
Q3. Why do bind mounts break with a remote context?
Paths are read on the server, not on the laptop.
Q4. Why deploy tags instead of building on the server?
Faster, repeatable, the server needs no source, and rollback is just the old tag.
Q5. How does the script roll back?
If the new container is not healthy in time, it starts the previous tag.
Samajla ka?
Revision card
Interview card