You have a server. It's old. Maybe it's running on modest hardware, maybe the network is slow, or maybe your project has grown to the point where a full docker compose build takes 10–15 minutes. Waiting for that every time you deploy a change is painful — especially when the change was two lines of Python.
There's a better way: build once, ship the image.
First, Let's Clarify the Terminology
Before diving into commands, it's worth making sure we're on the same page about three terms that often get mixed up.
Docker Build
A build is the process of turning a Dockerfile + build context (your source files) into an image. This is what's slow on your old server. It runs through every instruction — installing packages, copying files, compiling assets — layer by layer.
Dockerfile + source code → (docker build) → Image
The key insight: you only need to build once. After that, you can move the resulting image anywhere without rebuilding.
Docker Image
A Docker image is a read-only, layered snapshot of a filesystem. Think of it as a blueprint or a template. It contains your OS base layer, installed packages, your application code, environment config — everything needed to run your app, frozen in time.
Images are built from a Dockerfile. Once built, they don't change. They're portable: the same image works on your laptop, your CI server, and your production box.
Dockerfile → (docker build) → Image
Docker Container
A container is a running (or stopped) instance of an image. If an image is a blueprint, a container is the actual building. You can spin up multiple containers from the same image — they all start from the same state but run independently.
Image → (docker run) → Container (running process with its own writable layer)
Containers are ephemeral by design. Restart one and it starts clean from the image again (unless you're using volumes for persistence).
The Core Idea: Build Locally, Deploy the Image
Instead of pulling source code onto your server and building there, you build the image on your development machine (or CI), export it, transfer it, and load it on the target server.
[Dev Machine] [Old Server]
docker build → image.tar → docker load → docker run
Method 1: docker save / docker load (Direct Transfer)
This is the most straightforward approach. No registry needed.
Step 1: Build the image locally
docker build -t myapp:latest .
Step 2: Save the image to a tar archive
docker save myapp:latest -o myapp.tar
If you want to save multiple images (e.g., your app + a sidecar):
docker save myapp:latest nginx:alpine -o bundle.tar
Step 3: Compress it (optional but recommended)
Docker images can be large. Gzip cuts the size significantly:
gzip myapp.tar
# or in one step:
docker save myapp:latest | gzip > myapp.tar.gz
Step 4: Transfer to the server
scp myapp.tar.gz user@your-server:/home/user/
# or with rsync for resumable transfers:
rsync -avz --progress myapp.tar.gz user@your-server:/home/user/
Step 5: Load the image on the server
ssh user@your-server
gunzip -c myapp.tar.gz | docker load
# or if it's not compressed:
docker load -i myapp.tar
Step 6: Run it
docker run -d --name myapp -p 8000:8000 myapp:latest
# or with compose, if your compose file references the image name:
docker compose up -d
Method 2: Tag and Push to a Registry
If you're doing this repeatedly, a private Docker registry is cleaner than manually moving tarballs around.
Option A: Docker Hub (simplest)
docker tag myapp:latest yourusername/myapp:latest
docker push yourusername/myapp:latest
On the server:
docker pull yourusername/myapp:latest
docker compose up -d
Option B: Self-hosted Registry
If your server has a registry running (or you want to spin one up):
# On the registry host:
docker run -d -p 5000:5000 --name registry registry:2
# On dev machine:
docker tag myapp:latest your-registry-host:5000/myapp:latest
docker push your-registry-host:5000/myapp:latest
# On the target server:
docker pull your-registry-host:5000/myapp:latest
Option C: GitHub Container Registry (ghcr.io)
A solid free option for private images, especially if you're already on GitHub:
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
docker tag myapp:latest ghcr.io/your-org/myapp:latest
docker push ghcr.io/your-org/myapp:latest
Tips for Working with Slow Servers
Use .dockerignore — Exclude node_modules, .git, __pycache__, test files, etc. from your build context. Even if you're building locally, a leaner context means smaller images to transfer.
# .dockerignore
.git
node_modules
__pycache__
*.pyc
.env
Tag your images meaningfully — Don't rely only on latest. Tag with a git commit hash or version:
docker build -t myapp:$(git rev-parse --short HEAD) .
docker tag myapp:$(git rev-parse --short HEAD) myapp:latest
This lets you roll back instantly without rebuilding — just docker run the previous tag.
Take advantage of layer caching — When building locally, structure your Dockerfile so dependencies (the slow part) come before application code (the fast part). This way, most layers are already cached:
# Install deps first — cached unless requirements change
COPY requirements.txt .
RUN pip install -r requirements.txt
# Then copy app code — invalidates cache here, but that's fast
COPY . .
Use --compress with docker save alternatives — If your server connection is very slow, consider zstd instead of gzip for faster compression at similar ratios:
docker save myapp:latest | zstd -T0 > myapp.tar.zst
# Transfer...
zstd -d myapp.tar.zst --stdout | docker load
Putting It All Together: A Simple Deploy Script
Here's a shell script that wraps the full local-build → transfer → reload workflow:
#!/bin/bash
set -e
IMAGE_NAME="myapp"
IMAGE_TAG=$(git rev-parse --short HEAD)
REMOTE_USER="user"
REMOTE_HOST="your-server"
REMOTE_PATH="/home/user"
echo "→ Building image..."
docker build -t $IMAGE_NAME:$IMAGE_TAG -t $IMAGE_NAME:latest .
echo "→ Exporting image..."
docker save $IMAGE_NAME:latest | gzip > /tmp/$IMAGE_NAME.tar.gz
echo "→ Transferring to server..."
rsync -avz --progress /tmp/$IMAGE_NAME.tar.gz $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/
echo "→ Loading image on server..."
ssh $REMOTE_USER@$REMOTE_HOST "gunzip -c $REMOTE_PATH/$IMAGE_NAME.tar.gz | docker load"
echo "→ Restarting services..."
ssh $REMOTE_USER@$REMOTE_HOST "cd /your/app && docker compose up -d"
echo "✓ Done"
Summary
| Concept | What it is |
|---|---|
| Build | The process of creating an image from a Dockerfile |
| Image | Immutable snapshot of your app environment — the blueprint |
| Container | A running instance of an image — the actual process |
When your server is slow to build, stop building on it. Build the image where you have fast machines and good layer caches, then ship the artifact — not the source code. Whether you do this via docker save/load for simplicity or a private registry for repeatability, the principle is the same: separate the build step from the deploy step.
Your old server will thank you. Have you tried this, or do you have a better workflow? Let me know in the comments!
Top comments (0)