Build a production-ready application stack with Docker Compose
Package a small web service with a non-root image, healthchecks, an internal data network, persistent storage, reproducible configuration, backup, update, and rollback.
Demonstrate a complete single-host Compose lifecycle without exposing the data service or treating container recreation as a backup strategy.
- Docker Engine 28.x+
- Docker Compose 2.30+
- Ubuntu Server 24.04 LTS
- Docker baseline Complete the Docker installation guide and confirm the daemon plus Compose plugin are available.
docker version && docker compose version - Capacity Confirm free disk, memory, and a backup destination outside the application volume.
df -h /var/lib/docker && free -h - Ingress plan Keep the example bound to loopback unless an authenticated reverse proxy or firewall is ready.
- Source control Store application and Compose files in version control, but exclude secrets, .env, and backup archives.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A small Flask and Redis application packaged as a non-root image and described by a version-controlled Compose model.
- Two services on an internal backend network, with only the web service published to host loopback and Redis persisted in a named volume.
- Health-gated startup, read-only application filesystem, no-new-privileges, bounded operational inspection and an explicit update rollback image.
- An application-consistent Redis save followed by a volume archive stored outside the volume, plus evidence that the archive contains expected persistence files.
- Compose renders without structural errors or accidental secret and port exposure.
- Both services become healthy, the loopback application increments state, and Redis has no published host port.
- A backup archive lists Redis persistence files and can be restored into a separate volume during a later rehearsal.
- One reviewed web update can be deployed independently, health-checked, and reverted to the preserved rollback image.
Architecture
How the parts fit together
Compose creates one application container and one Redis container. The web process joins the default and internal backend networks; Redis joins only backend. A named volume outlives Redis containers, while loopback port publishing requires an external reverse proxy for public access.
- A local reverse proxy or operator connects to 127.0.0.1:8080.
- Docker forwards the connection to Gunicorn on port 8000 in the web container.
- The web service resolves redis on the internal backend network and updates persistent data.
- Redis writes AOF/RDB state into the named volume; the backup workflow requests SAVE before archiving that volume read-only.
Assumptions
- Docker Engine 29.x and Compose v2.30 or later already pass the hardened installation guide.
- The example is a single-host teaching stack, not a high-availability deployment. Host failure stops both application and data service.
- A reverse proxy will publish loopback port 8080 if public traffic is required. Redis remains private.
- The backup directory is on storage with adequate capacity and an additional copy will leave the Docker host.
- The application files are version-controlled while .env, secrets and backup archives are excluded.
Key concepts
- Build context
- The directory tree sent to the Docker builder. .dockerignore prevents secrets and irrelevant files from entering it.
- Named volume
- Docker-managed persistent storage with a lifecycle independent of an individual container.
- Healthcheck
- A command run inside a container to report healthy or unhealthy application state, not merely process existence.
- Internal network
- A Compose network with no external connectivity path, used here to isolate the Redis data service.
- Application-consistent backup
- A backup taken after the application or database has flushed a coherent state, rather than copying files during unknown writes.
- Recreate
- Compose replaces a service container from the current model while preserving declared named volumes.
Security and production boundaries
- The demo has no user authentication. Keep it on loopback and add an authenticated TLS reverse proxy before public use.
- Environment variables are visible through container inspection and are not a secret store. Use Compose secrets or an external secret manager.
- Redis is intentionally unexposed, but network isolation does not replace authentication and authorization for a real multi-tenant deployment.
- Image tags can move. Record tested digests and scan build inputs before production release.
- A volume archive is not verified until restored into an isolated volume and checked by the application.
Stop before continuing if
- Stop if Docker or Compose versions do not support --wait and the health-based dependency model used here.
- Stop if docker compose config reveals a public Redis port, host-root bind mount, secret value or privileged service.
- Stop if either service is unhealthy; do not route production traffic based on container running state.
- Stop if the backup archive is empty or Redis SAVE fails.
- Stop an update if health fails and restore the rollback image before pruning images or changing the data volume.
command
Create a restricted application workspace
Keep code, runtime configuration, secrets, and backups in explicit paths with conservative permissions.
Why this step matters
Separate source, secrets and backups under a restricted application root so permissions, version control and retention can be managed independently.
What to understand
install creates every named directory with explicit owner, group and mode. The brace expression expands to app, secrets and backups.
Mode 0750 permits the owner and approved group to traverse while denying other local users.
System changes
- Creates /opt/compose-demo and its app, secrets and backups directories.
Syntax explained
install -d- Creates directories rather than copying a file.
-m 0750- Sets owner full access, group read/execute and no access for others.
-o / -g- Assigns ownership to the invoking administrator.
{app,secrets,backups}- Bash expands three explicit child paths.
sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/compose-demo/{app,secrets,backups} && cd /opt/compose-demo/opt/compose-demo/app /opt/compose-demo/secrets /opt/compose-demo/backups
Checkpoint: Verify workspace ownership
find /opt/compose-demo -maxdepth 1 -printf '%M %u:%g %p\n'Continue whenThe root and three child directories are owned by the intended user with no other-user permissions.
Stop whenA path is world-writable, points elsewhere or already contains unknown state.
Security notes
- Keep secrets and backups outside the image build context.
Alternatives
- Use a dedicated service account and group for team-managed deployments.
Stop conditions
- Do not reuse an unreviewed existing /opt/compose-demo tree.
config
Create the web service and pinned dependency ranges
The sample exposes a health endpoint and uses Redis only on the internal Compose network. Pin compatible major versions before a real release.
Why this step matters
A minimal deterministic application makes health, persistence and network behavior observable without hiding Compose failures behind a complex codebase.
What to understand
The health route pings Redis so healthy means the critical dependency is reachable. The index route increments a persistent counter.
Compatible major ranges demonstrate dependency intent but a real release must lock exact resolved versions and hashes.
System changes
- Creates app.py and requirements.txt under the application build context.
Syntax explained
REDIS_HOST- Selects the Compose DNS name for the data service.
@app.get('/health')- Defines a read-only dependency health endpoint.
cache.incr- Atomically increments the persisted visit counter.
>= and <- Constrain dependencies to one compatible major series; a lock remains required for reproducibility.
/opt/compose-demo/app/app.pyfrom flask import Flask
import os
import redis
app = Flask(__name__)
cache = redis.Redis(host=os.getenv("REDIS_HOST", "redis"), port=6379)
@app.get("/health")
def health():
cache.ping()
return {"status": "ok"}
@app.get("/")
def index():
return {"visits": cache.incr("visits")}
# requirements.txt
Flask>=3.1,<4
redis>=6,<7
gunicorn>=23,<24app.py and requirements.txt saved under /opt/compose-demo/app
Checkpoint: Review source and dependencies
Continue whenThe health path is side-effect free except for Redis PING, the app uses service-name DNS and no secret is embedded.
Stop whenCredentials, public endpoints or unbounded dependency versions are present.
Security notes
- This demonstration endpoint has no authentication and must remain private.
Alternatives
- Substitute the real application only after it offers an equivalent deterministic health endpoint.
Stop conditions
- Do not proceed with secrets committed in source or requirements.
config
Build a non-root application image
Use a small official base, install only runtime dependencies, and run the process as an unprivileged numeric identity.
Why this step matters
Build a small runtime image that drops root before execution and uses exec-form CMD for predictable signal handling.
What to understand
Dependencies are installed before source copy so BuildKit can reuse the expensive layer when code changes.
USER 10001 avoids dependence on a host username and ensures Gunicorn is not container root. EXPOSE is documentation; Compose controls publication.
System changes
- Creates the Dockerfile used to build the local web image.
Syntax explained
FROM python:3.13-slim- Selects the reviewed official slim Python base tag; pin its digest for release.
pip install --no-cache-dir- Installs runtime packages without retaining pip's download cache.
useradd --uid 10001- Creates a fixed unprivileged runtime identity.
USER 10001- Runs later build/runtime instructions as the unprivileged identity.
CMD [ ... ]- Uses exec form so Gunicorn receives container signals directly.
/opt/compose-demo/app/DockerfileFROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && useradd --uid 10001 --no-create-home appuser
COPY app.py .
USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]Dockerfile saved; final runtime user is UID 10001
Checkpoint: Inspect the planned runtime identity
Continue whenThe last USER is 10001, no secret is copied, and the command binds only the container port.
Stop whenThe final process runs as root or the build copies the entire workspace.
Security notes
- A non-root process can still exploit kernel or mounted-resource flaws; retain other controls.
Alternatives
- Use a prebuilt signed application image from CI rather than building on the host.
Stop conditions
- Do not deploy an image whose final user or provenance is unknown.
config
Define health, isolation, persistence, and restart behavior
Publish only the web service on loopback. Keep Redis private, persist it in a named volume, and require both services to pass healthchecks.
Why this step matters
Declare exposure, dependency health, writable state, restart behavior and network isolation in one reviewable model.
What to understand
Only 127.0.0.1:8080 is published. Redis receives no ports entry and joins an internal network, so it is reachable only by attached containers.
The web root filesystem is read-only and /tmp is ephemeral. Redis state lives in a named volume and append-only persistence is enabled.
System changes
- Creates compose.yaml; later Compose operations create networks, containers and the redis-data volume from it.
Syntax explained
127.0.0.1:8080:8000- Publishes container port 8000 only on host loopback port 8080.
depends_on condition: service_healthy- Waits for Redis health before creating web.
read_only / tmpfs- Makes the image filesystem immutable while providing ephemeral /tmp.
security_opt: no-new-privileges- Prevents privilege gain through setuid or file capabilities.
backend internal: true- Creates an isolated data network without external connectivity.
redis-data:/data- Persists Redis files in a named volume.
healthcheck test- Defines commands that report application readiness to Docker.
/opt/compose-demo/compose.yamlservices:
web:
build: ./app
ports:
- "127.0.0.1:8080:8000"
environment:
REDIS_HOST: redis
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
networks:
- backend
- default
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 10s
timeout: 3s
retries: 5
redis:
image: redis:8-alpine
command: ["redis-server", "--appendonly", "yes"]
restart: unless-stopped
volumes:
- redis-data:/data
networks:
- backend
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
backend:
internal: true
volumes:
redis-data:compose.yaml saved with one published loopback port and one named volume
Checkpoint: Review exposure and persistence
docker compose config --quiet && docker compose configContinue whenOnly web loopback 8080 is published, Redis has a named volume and both healthchecks render.
Stop whenRedis has a host port, a secret renders, or a service is privileged.
Security notes
- Internal networking limits routes but does not authenticate Redis clients.
Alternatives
- Use external managed networks only when ownership and lifecycle are explicit.
Stop conditions
- Do not start until the rendered model matches the intended trust boundaries.
config
Exclude local state from the image build
Prevent Git metadata, environment files, secrets, caches, and backups from entering the build context.
Why this step matters
The builder can read every file in its context, so exclude secrets, Git history, backups and caches before any build.
What to understand
.dockerignore is evaluated relative to the build context ./app, not the Compose project root.
Keeping the backup directory outside ./app provides an additional boundary; the ignore entries defend against future layout changes.
System changes
- Creates /opt/compose-demo/app/.dockerignore and changes future build context contents.
Syntax explained
.git / .env- Exclude source-control internals and environment files.
secrets/ / backups/- Exclude credential and recovery directories if they enter the context.
__pycache__/ / *.pyc- Exclude local interpreter caches.
/opt/compose-demo/app/.dockerignore.git
.env
secrets/
backups/
__pycache__/
*.pycapp/.dockerignore excludes secrets and mutable state from the web image
Checkpoint: Inspect the sent context
docker build --no-cache --progress=plain /opt/compose-demo/app 2>&1 | sed -n '/transferring context/p'Continue whenThe context is small and the build contains only source, requirements and Dockerfile.
Stop whenThe context unexpectedly includes large archives or secret paths.
Security notes
- Once a secret enters an image layer, deleting it in a later layer does not remove it from history.
Alternatives
- Use BuildKit secret mounts for build-time credentials that must never enter layers.
Stop conditions
- Stop and rotate any secret accidentally sent to an untrusted builder or registry.
verification
Render and inspect the effective configuration
Compose validation catches structural errors and shows merged values. Review the output for accidental public ports, bind mounts, and secret values.
Why this step matters
Render Compose's merged model before creating resources so interpolation, defaults, image names, volumes and accidental exposure can be reviewed.
What to understand
--quiet checks validity without dumping the full model. Separate image and volume lists create concise inventory evidence.
Run from /opt/compose-demo so Compose selects the intended project and file. Set an explicit project name if directory-derived naming is unacceptable.
System changes
- No Docker resources are created; Compose reads and normalizes configuration.
Syntax explained
config --quiet- Validates the model without printing it.
config --images- Lists effective images.
config --volumes- Lists declared named volumes.
docker compose config --quiet && docker compose config --images && docker compose config --volumescompose-demo-web redis:8-alpine compose-demo_redis-data
Checkpoint: Approve the effective inventory
docker compose config --quiet && docker compose config --images && docker compose config --volumesContinue whenOnly the web build, reviewed Redis image and compose-demo_redis-data volume appear.
Stop whenInterpolation is unresolved, an image uses latest unexpectedly, or unknown volumes appear.
Security notes
- Full config output may reveal interpolated secrets; this baseline uses concise inventory commands.
Alternatives
- Validate the same model in CI before copying it to the host.
Stop conditions
- Do not run up after any config warning or unexpected resource.
command
Build and start the stack
Build from the reviewed context, start in detached mode, and wait for healthchecks rather than assuming a running process is ready.
Why this step matters
Build from the reviewed context and wait for declared health, rather than treating a started process as a ready application.
What to understand
--detach returns control to the operator, --build refreshes the local image, and --wait blocks until services are running or healthy.
The 120-second timeout bounds the change. If it expires, inspect health and logs instead of repeatedly recreating services.
System changes
- Builds the web image and creates the project networks, Redis volume, and two service containers.
Syntax explained
up- Creates or reconciles services, networks and volumes.
--detach- Runs services in the background.
--build- Builds images before starting containers.
--wait- Waits for services to become running or healthy.
--wait-timeout 120- Limits waiting to 120 seconds.
docker compose up --detach --build --wait --wait-timeout 120Container compose-demo-redis-1 Healthy Container compose-demo-web-1 Healthy
Checkpoint: Require healthy services
docker compose psContinue whenweb and redis are Up and healthy; web publishes loopback only.
Stop whenA service exits, remains unhealthy, or exposes an unintended host address.
If this step fails
Compose times out waiting for health.
Likely causeA healthcheck command is missing, the app dependency is unavailable, or startup exceeds the selected timeout.
docker compose psdocker compose logs --tail 100 --timestamps
ResolutionFix the healthcheck or service failure; increase timeout only with evidence of legitimate startup time.
Security notes
- Building pulls base content; production should use approved registries and digests.
Alternatives
- Deploy a prebuilt image with pull_policy and digest pinning.
Stop conditions
- Do not route traffic to an unhealthy or unexpectedly exposed service.
verification
Test behavior, health, and network exposure
Confirm the application increments state, both containers are healthy, and Redis has no host port.
Why this step matters
Verify application behavior, persisted state and network exposure together so a green healthcheck cannot hide a broken route or public Redis port.
What to understand
The first request checks dependency health, the second mutates the counter predictably, and compose ps reports health and published ports.
Repeat the counter request after recreating web to prove state belongs to Redis rather than the web container.
System changes
- Increments the Redis visits key through the application; otherwise reads runtime state.
Syntax explained
curl --fail --silent- Requires successful HTTP status and prints only response data.
docker compose ps- Shows project containers, health and published ports.
curl --fail --silent http://127.0.0.1:8080/health && echo && curl --fail --silent http://127.0.0.1:8080/ && echo && docker compose ps{"status":"ok"}
{"visits":1}
web Healthy 127.0.0.1:8080->8000/tcp
redis HealthyCheckpoint: Prove behavior and isolation
curl --fail --silent http://127.0.0.1:8080/health && docker compose psContinue whenHealth is ok, both services are healthy and Redis has no host port.
Stop whenHealth fails, Redis is published, or the response comes from another process.
Security notes
- Loopback binding still permits any authorized local process to connect.
Alternatives
- Add protocol-level checks from the reverse proxy host when it is separate.
Stop conditions
- Do not proceed to backup until application and persistence are healthy.
command
Back up and inspect the data volume
Request a Redis save, archive the named volume to the dedicated backup directory, and list the archive before treating it as usable.
Why this step matters
Request an application-consistent Redis snapshot before archiving the volume, then inspect the archive rather than assuming file creation means backup success.
What to understand
redis-cli SAVE blocks until Redis writes an RDB snapshot. The disposable Alpine container mounts data read-only and backups read-write.
The archive is stored outside the named volume. Listing it proves structure, but a separate restore test is still required.
System changes
- Creates or updates Redis persistence files and writes /opt/compose-demo/backups/redis-data.tgz.
Syntax explained
compose exec redis redis-cli SAVE- Requests a synchronous Redis snapshot in the running data service.
docker run --rm- Uses a disposable helper container removed after exit.
--volume ...:/data:ro- Mounts the source volume read-only.
--volume ...:/backup- Makes the host backup directory writable in the helper.
tar -czf ... -C /data .- Creates a gzip archive using volume contents as its root.
tar -tzf- Lists a gzip archive without extracting it.
docker compose exec redis redis-cli SAVE && docker run --rm --volume compose-demo_redis-data:/data:ro --volume /opt/compose-demo/backups:/backup alpine:3.22 tar -czf /backup/redis-data.tgz -C /data . && tar -tzf /opt/compose-demo/backups/redis-data.tgz | headOK ./ ./appendonlydir/ ./dump.rdb
Checkpoint: Require non-empty backup evidence
test -s /opt/compose-demo/backups/redis-data.tgz && tar -tzf /opt/compose-demo/backups/redis-data.tgz | headContinue whenThe archive is non-empty and lists Redis persistence files.
Stop whenSAVE fails, archive is empty, tar reports corruption or expected files are absent.
If this step fails
The helper cannot write redis-data.tgz.
Likely causeBackup directory ownership, mount path or filesystem capacity is wrong.
df -h /opt/compose-demo/backupsstat -c '%U:%G %a %n' /opt/compose-demo/backups
ResolutionCorrect the dedicated backup destination and create a new timestamped archive; do not loosen it world-writable.
Security notes
- Backups may contain sensitive application data; encrypt and copy them off-host.
Alternatives
- Use Redis-native replication or managed snapshots when recovery objectives require them.
Stop conditions
- Do not delete or recreate the volume until an isolated restore has been proven.
decision
Deploy one reviewed change
Tag the currently working image, build the replacement, recreate only the web service, and verify health before pruning anything.
Why this step matters
Preserve the currently working image before building one reviewed change, update only web, and use health as the promotion gate.
What to understand
docker tag creates a stable local rollback reference to the current image content. --no-deps avoids recreating healthy Redis.
The final curl validates user-visible health. Preserve logs and rollback image until the new release has passed an observation window.
System changes
- Creates a rollback image tag, builds a replacement web image, and recreates only the web container.
Syntax explained
docker tag- Adds a rollback reference to existing image content without rebuilding.
build --pull web- Builds web while checking for a newer referenced base image.
up --detach --no-deps --wait web- Recreates only web and waits for health without touching Redis.
docker tag compose-demo-web:latest compose-demo-web:rollback && docker compose build --pull web && docker compose up --detach --no-deps --wait web && curl --fail http://127.0.0.1:8080/health{"status":"ok"}Checkpoint: Promote only a healthy replacement
curl --fail http://127.0.0.1:8080/health && docker compose ps webContinue whenThe replacement is healthy and the application responds successfully.
Stop whenBuild, startup, health or application response fails.
If this step fails
The replacement web service is unhealthy.
Likely causeApplication, dependency or base-image behavior changed.
docker compose logs --tail 100 webdocker image inspect compose-demo-web:rollback
ResolutionReference compose-demo-web:rollback, recreate web only and repeat health verification.
Security notes
- --pull can introduce new base content; production builds should be tested in CI and pinned by digest.
Alternatives
- Deploy an immutable prebuilt image and keep the previous digest in version control.
Stop conditions
- Do not prune images or modify Redis while the new web release is unproven.
verification
Review bounded logs and resource state
Inspect recent logs, container health, restart counts, and volume inventory without following an unbounded stream.
Why this step matters
Bounded logs, health, restarts and volume inventory provide a post-change snapshot without attaching to an endless production stream.
What to understand
--tail limits log volume and --timestamps preserves event ordering. Compose ps shows health and restart state.
Volume inspect confirms the named volume identity and mountpoint metadata; it does not validate application data.
System changes
- No persistent changes; reads recent logs and Docker metadata.
Syntax explained
logs --tail 100- Reads at most one hundred recent lines per service.
--timestamps- Adds RFC3339-style event timestamps.
volume inspect- Shows Docker's metadata for the named volume.
docker compose logs --tail 100 --timestamps && docker compose ps && docker volume inspect compose-demo_redis-dataweb-1 | Booting worker redis-1 | Ready to accept connections Status: healthy
Checkpoint: Capture the stable operating state
docker compose logs --tail 100 --timestamps && docker compose ps && docker volume inspect compose-demo_redis-dataContinue whenNo repeating errors, both services healthy and the expected named volume present.
Stop whenRestarts rise, logs repeat failures or the volume identity differs from backup evidence.
Security notes
- Application logs may contain user data; restrict and redact before sharing.
Alternatives
- Ship structured logs and metrics to an off-host monitoring system.
Stop conditions
- Keep the rollback image and backup until the observation window remains clean.
Finish line
Verification checklist
curl --fail --silent http://127.0.0.1:8080/healthReturns {"status":"ok"}.docker compose ps --format jsonWeb and Redis report healthy with no unexpected published ports.tar -tzf /opt/compose-demo/backups/redis-data.tgz | headLists Redis persistence files from a non-empty archive.Recovery guidance
Common problems and safe checks
The web container remains unhealthy while Redis is healthy.
Likely causeThe application failed to start, Gunicorn is not listening, or the healthcheck cannot reach its endpoint.
docker compose psdocker compose logs --tail 100 webdocker compose exec web python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/health').read())"
ResolutionCorrect the image command, dependencies or health path, rebuild only web and wait for health before exposing traffic.
The web service cannot resolve or connect to redis.
Likely causeThe services do not share backend, Redis is unhealthy, or the REDIS_HOST value was changed.
docker compose configdocker compose psdocker compose exec web getent hosts redis
ResolutionRestore shared backend membership and the service-name host, then recreate web after Redis is healthy.
The backup tar is created but contains no Redis persistence files.
Likely causeThe wrong volume name was mounted, SAVE failed, or persistence paths differ from the expected image.
docker compose exec redis redis-cli INFO persistencedocker volume inspect compose-demo_redis-datatar -tzf /opt/compose-demo/backups/redis-data.tgz
ResolutionIdentify the effective volume and persistence state, request a successful save, and create a new archive without overwriting known evidence.
A new image deploys but the health check fails.
Likely causeThe build introduced an incompatible dependency, command or application change.
docker compose logs --tail 100 webdocker image inspect compose-demo-web:rollbackdocker compose config
ResolutionPoint web at compose-demo-web:rollback, recreate only web, verify health, then debug the replacement offline.
Reference
Frequently asked questions
Why does depends_on use service_healthy?
Start order alone does not mean Redis is ready. The long syntax waits for the declared healthcheck before creating the dependent web service.
Is docker compose down safe for the data?
Without --volumes it removes containers and networks but preserves named volumes. Always inspect the exact command and never add --volumes during routine rollback.
Is the tar archive enough as a Redis backup?
It is evidence only. Restore it into an isolated new volume and start a test Redis instance before counting it toward recovery objectives.
Recovery
Rollback
Recreate the web service from the preserved rollback image and keep the named data volume unless a tested restore is explicitly required.
- Set the web image to compose-demo-web:rollback or restore the previous reviewed Git revision.
- Run docker compose up --detach --no-deps --wait web and repeat the application healthcheck.
- If data rollback is required, stop Redis, preserve the current volume, and restore the tested archive into a new volume before switching.
- Use docker compose down without --volumes when retiring the stack; remove volumes only after backup retention is confirmed.
Evidence