I have been running many Docker containers to experiment with new tools. Gradually, I have forgotten a lot of the container parameters I used when I docker run them initially. As some of the containers become the backbone of my projects, I realize that I’ve come to an inflection point that separates quick experiments from reproducible infrastructure. So I started looking for a more formal way to maintain artifacts that can be ported to another VM and run with EXACTLY the same settings.
In this post, I will outline how we can move from “imperative” (docker run …) to declarative (docker‑compose.yml + .env) so we never have to remember or reverse‑engineer container parameters again.
Why does this matter?
- Portability: One Compose
yml+ one.envfile are all you need. This makes your files portable artifacts. You can copy them to another machine, rundocker compose up -d, and you’ll get the same port mapping, env vars, mounts, command and restart policies.
- Auditability: You can version‑control these files, so you have a clear view of what/when/where a file is changed.
- Reproducibility: No more “what flags did I use last time?”. The containers will be up running with identical settings repetitively.
- Extensibility: Easy to add nginx, certbot, Postgres, etc. as services in the same Compose file.
Our ingredients
1. docker-compose.yml
Docker Compose isn’t just for multi‑container stacks. It produces a declarative artifact that captures all the settings you’d otherwise pass to docker run. Even if you only have one container, Compose gives you:
- A single YAML file you can copy to another VM to recreate the container identically.
- Explicit documentation of environment variables, mounts, ports, restart policies, etc.
- Easy integration with
.envfiles so you don’t hard‑code secrets or host‑specific values.
Example:
services:
django:
image: my-django-app:latest
ports:
- "443:443"
- "80:80"
environment:
- DJANGO_SETTINGS_MODULE=myproject.settings
- ALLOWED_HOSTS=django-domain.com
volumes:
- ./data:/app/data
- ./logs:/var/log/django
restart: unless-stopped2. .env file
The reasons to separate .env from the yml are:
- Configuration hygiene: Keeps secrets, environment‑specific and changeable values out of your versioned YAML.
- Portability: You can ship the same
docker-compose.ymlto multiple hosts and swap only.envthat are specific to each hosting environment. - Security and rotation: Rotate values without editing YAML; .
envcan be managed with vaults or OS‑level permissions. - Clarity: The YAML shows structure; the
.envshows values, making it easier to audit and diff.
Example:
DJANGO_SETTINGS_MODULE=myproject.settings
ALLOWED_HOSTS=django-domain.com
Then reference it in docker-compose.yml:
env_file:
- .env3. Infrastructure as Code
For larger setups, tools like Docker Swarm, Kubernetes, or Ansible can manage this at scale. But for a single VM, docker-compose is the sweet spot.
Our automation workflow
- Inspect our current container to recover all config settings.
- Write them into a
docker-compose.ymland.env.
- Stop and remove the old container.
- Start it again with
docker compose up -d.
From now on, we can move the compose file + env file to any VM and recreate the container exactly.
Step 1: Container config -> Compose yaml
docker inspect can capture the entire runtime configuration (env vars, ports, mounts, entrypoint, command, etc.) of a container. To translate them into a reproducible and portable docker-compose.yml artifact, there are two ways.
We can leverage the automated tools in section A, or manually transform the inspect values into Compose syntax ourselves in section B. To illustrate the concept, I will go over the manual steps.
A: Automated tools
docker‑inspect2composecan generate a Compose service definition from a running container. This is extremely helpful if you started a container usingdocker runso long ago, you don’t quite remember the volume mounts and other options you used.
composerizeconverts adocker runcommand into Compose YAML. This requires you to know the exact parameters.docker-replaygeneratedocker runcommand and options from running containers.
B: Manual (inspect → compose)
Let’s map docker inspect JSON fields to their corresponding docker-compose.yml keys in this section.
1. Get full inspect output
docker inspect <container-id> > container.jsonThis shows:
- Environment variables (
.Config.Env) - Mounts (
.Mounts) - Ports (
.HostConfig.PortBindings) - Command/entrypoint
For example,
.Config.Env→environment:
.Config.Cmdand.Config.Entrypoint→command:/entrypoint:
.Config.WorkingDir→working_dir:
.HostConfig.PortBindings→ports:
.Mounts→volumes:
2. Map .Config fields
This section shows only the container’s internal configuration (what the image and docker run command defined).
docker inspect path |
docker-compose.yml key |
Notes |
|---|---|---|
.Config.Image |
image: |
Required |
.Config.Hostname |
hostname: |
Optional unless needed |
.Config.WorkingDir |
working_dir: |
Optional |
.Config.Entrypoint |
entrypoint: |
Optional |
.Config.Cmd |
command: |
Required if entrypoint is not self-sufficient |
.Config.Env[] |
environment: |
Convert array to YAML list or key-value pairs |
.Config.Labels |
labels: |
Optional |
.Config.ExposedPorts |
Not needed | Compose uses ports: instead from .HostConfig.PortBindings |
3. Map .HostConfig fields
This section defines configurations for the host, e.g., host‑side port bindings.
docker inspect path |
docker-compose.yml key |
Notes |
|---|---|---|
.HostConfig.PortBindings |
ports: |
Format: "hostPort:containerPort" |
.HostConfig.Binds |
volumes: |
Format: "hostPath:containerPath" |
.HostConfig.RestartPolicy.Name |
restart: |
e.g., always, unless-stopped |
.HostConfig.Privileged |
privileged: |
Optional |
.HostConfig.NetworkMode |
network_mode: |
Optional; use only if not bridge |
.HostConfig.CapAdd |
cap_add: |
Optional |
.HostConfig.CapDrop |
cap_drop: |
Optional |
4. Map .Mounts (if present)
If the old container has app data (media, logs, DB), ensure they’re mapped via volumes: in the YAML so data survives container recreation.
docker inspect path |
docker-compose.yml key |
Notes |
|---|---|---|
.Mounts[].Source + .Destination |
volumes: |
Format: "source:destination" |
.Mounts[].ReadOnly |
Add :ro if true |
e.g., "./data:/app/data:ro" |
5. Recreate config in docker-compose.yml
Using this Compose template:
services:
<your-service-name>:
image: <from .Config.Image>
container_name: <optional>
working_dir: <from .Config.WorkingDir>
entrypoint: <from .Config.Entrypoint>
command: <from .Config.Cmd>
ports:
- "<host>:<container>" # from .HostConfig.PortBindings
volumes:
- "<host>:<container>" # from .Mounts or .HostConfig.Binds
environment:
- VAR=value # from .Config.Env[]
restart: unless-stopped # from .HostConfig.RestartPolicy.Name
privileged: true # if .HostConfig.Privileged is trueWe can translate this docker inspect snippet:
"Env": [
"HOSTNAME=custom-domain.com",
"PYTHONDONTWRITEBYTECODE=1",
"PYTHONUNBUFFERED=1"
],
"Cmd": [
"gunicorn",
"--bind",
"0.0.0.0:8000",
"django.wsgi"
],
"Image": "django-app",
"WorkingDir": "/app",
"ExposedPorts": {
"8000/tcp": {}
},
"PortBindings": {
"8000/tcp": [
{
"HostPort": "7777"
}
]
}into this docker-compose.yml in a project directory (e.g., /compose)
services:
django:
image: django-app
container_name: django_container
working_dir: /app
command: >
gunicorn --bind 0.0.0.0:8000 django.wsgi
ports:
- "7777:8000"
environment:
- HOSTNAME=custom-domain.com
- PYTHONDONTWRITEBYTECODE=1
- PYTHONUNBUFFERED=1
# Add volumes if needed:
# volumes:
# - ./data:/app/data
# - ./certs:/etc/letsencryptStep 2: .env for changeable values
Create a .env file in the same project directory (/compose) with your environment values (e.g., DJANGO_ALLOWED_HOSTS, secrets, ports):
DJANGO_ALLOWED_HOSTS=custom-domain.com,localhost,127.0.0.1,public-IP
Then refer it in docker-compose.yml:
env_file:
- .envStep 3: Recreate container from Compose
Move into your project directory
cd /composeStop the existing container with
docker stop <old-container-name-or-id>Remove the existing container with
docker rm <old-container-name-or-id>. This does not delete image or volumes.Launch with Compose
docker compose up -d.We will see an output:
[+] Running 2/2 ✔ Network compose_default Created 0.2s ✔ Container django-container StartedCompose creates:
- A network named after the project (by default, the directory name) with
_defaultappended. In our case, the directory name iscompose, so the network iscompose_default. - All services in that Compose file are attached to this network unless you override it. It allows containers to talk to each other by service name instead of IP. For example: if you add an
nginxservice to this Compose file, you can set its upstream tohttp://django-container:8000and it will resolve automatically.
To view more information about this custom network, we can use:
docker network ls docker network inspect compose_defaultWe will see our django container listed as members.
- A network named after the project (by default, the directory name) with
Verify containers are healthy with
docker compose ps docker compose logs -fLook for gunicorn binding to
0.0.0.0:8000.Quick rollback
If something misbehaves, stop the Compose stack with
docker compose down.Relaunch the old container (using your saved
container.jsonas reference), or fix the YAML/.env and bring it back up withdocker compose up -d.
Conclusion
Now our container is both portable and reproducible. If we want to move to another VM, we can just execute the followings and the container will be running with the exact same config:
scp docker-compose.yml .env user@newvm:/compose/
ssh user@newvm
cd /compose
docker compose up -d