DEV Community

Cover image for Docker Transfers: The Lazy (and Fast) Way
Maksym
Maksym

Posted on

Docker Transfers: The Lazy (and Fast) Way

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

Step 2: Save the image to a tar archive

docker save myapp:latest -o myapp.tar
Enter fullscreen mode Exit fullscreen mode

If you want to save multiple images (e.g., your app + a sidecar):

docker save myapp:latest nginx:alpine -o bundle.tar
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

On the server:

docker pull yourusername/myapp:latest
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)