Sidecars, Utility Containers and One Off Jobs in Everyday Docker Use
AI generated
Docker · Compose · Sidecars · Init Containers
Sidecars, Utility Containers
and One Off Jobs in Everyday Use

Docker Compose can do more than launch main services. Sidecar containers forward logs and export metrics. Utility containers provide CLI tools without a local install. Init containers run before the main service and carry out database migrations. One off jobs handle maintenance tasks on demand. This article explains all four patterns, each with the right tool for the right purpose.

13 min read Sidecars · Init Containers · Utility Containers · One Off Jobs Docker Compose v2 · depends_on · restart policies

1. The four container patterns and when each one fits

Docker Compose is often used in a simple way: define a main service, add dependencies like a database and a cache, done. That covers only part of the real world, though. In production stacks there are always cross cutting tasks, things like logging, monitoring, database migrations and maintenance scripts, that do not belong to any single service. Four clearly distinct patterns handle these: sidecar containers, utility containers, init containers and one off jobs. Each has its own specific purpose and its own specific configuration in Docker Compose.

Telling them apart matters because picking the wrong one causes problems. Running a maintenance script as part of the main service means the main service is never cleanly started for every single script invocation. Running a database migration as a manual step outside of Docker breaks the idea of a reproducible stack start. A sidecar container built for a task that should only run once will just keep running pointlessly forever. Understanding the four patterns is the prerequisite for clean, manageable Compose stacks.

Context determines the choice: does the task run continuously alongside the main service? Then it is a sidecar container. Does it run once before startup? Init container. Is it triggered on demand from outside? Utility container. Does it run once in the context of the already running stack and then end? One off job. These four questions settle the choice in almost every case.

2. Sidecar containers: log shipping and metrics export

A sidecar container runs as a permanent companion to a main service. It shares network or volumes with the main service and extends its functionality without touching its code. The classic example is a log shipper: the main service writes logs to a shared volume, the sidecar container reads those logs and forwards them to a central logging system. The main service does not need to know anything about Elasticsearch or Loki, it simply writes files.

Another common sidecar container pattern is the metrics exporter. A PHP FPM instance or an Nginx server may not have a native Prometheus exporter. A sidecar container such as nginx-prometheus-exporter or php-fpm-exporter connects to the main service's status endpoint and exposes those metrics in a format Prometheus can scrape. The main service stays unchanged, yet monitoring is still complete.


# docker-compose.yml: sidecar pattern for log shipping and metrics
services:
  app:
    image: ghcr.io/myorg/myapp:latest
    volumes:
      # Shared log volume between main service and sidecar
      - app-logs:/var/log/app
    networks:
      - internal

  # Sidecar: forwards app logs to Loki
  log-shipper:
    image: grafana/promtail:latest
    restart: unless-stopped
    volumes:
      - app-logs:/var/log/app:ro          # read-only access to app logs
      - ./promtail.yml:/etc/promtail/config.yml:ro
    depends_on:
      app:
        condition: service_started
    networks:
      - internal

  # Sidecar: exports PHP-FPM metrics for Prometheus
  php-fpm-exporter:
    image: hipages/php-fpm_exporter:latest
    restart: unless-stopped
    environment:
      PHP_FPM_SCRAPE_URI: "tcp://app:9000/status"
    ports:
      - "9253:9253"
    depends_on:
      app:
        condition: service_healthy
    networks:
      - internal

volumes:
  app-logs:

networks:
  internal:

3. Utility containers: CLI tools without a local install

Utility containers solve a concrete problem in development teams: not every developer has the same version of PHP, Node.js, Composer or other tools installed locally. Utility containers wrap these tools inside a container and expose them via docker run or wrapper scripts. The team always uses the same tool version regardless of the local system, and nothing needs to be installed globally.

Utility containers do not run permanently, they start with a command, execute it and then stop. The pattern in Docker Compose: a service with no restart policy and with profiles, so it does not start on a normal docker compose up but can be invoked directly via docker compose run composer install. Alternatively they are called straight with docker run --rm, without being defined in the Compose file at all.

The Mark Shust Magento Docker setup applies this pattern consistently: the wrapper scripts in bin/ call tools inside a container without requiring a local install. bin/composer install starts a utility container with the correct Composer version, runs the command in the project directory and then stops. The developer never has to manage local PHP or Composer versions.

4. Init containers: preparation before the main service

Init containers solve the startup order problem: before the main service starts, the database must not only be reachable, it must also have the right schema. Docker Compose offers depends_on and healthchecks to define startup order, but only up to a point. An init container defined as a separate service that runs a database migration command and then exits with code 0 makes it possible to model complex preparation steps cleanly.

The trick lies in the restart: "no" policy combined with the condition: service_completed_successfully condition in depends_on. The main service only starts once the init container has finished successfully, not merely started. That is the crucial difference from condition: service_started: the latter only waits for the container to be running, not for it to have completed its task successfully and exited.


# docker-compose.yml: init container for database migration before app start
services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: appdb
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-prootpass"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - db-data:/var/lib/mysql

  # Init container: runs migration, exits 0 on success
  db-migrate:
    image: ghcr.io/myorg/myapp:latest
    command: ["php", "artisan", "migrate", "--force"]
    restart: "no"                          # run once, do not restart on exit
    depends_on:
      db:
        condition: service_healthy         # wait for DB to be ready, not just started
    environment:
      DB_HOST: db
      DB_DATABASE: appdb
      DB_PASSWORD: rootpass

  app:
    image: ghcr.io/myorg/myapp:latest
    restart: unless-stopped
    depends_on:
      db-migrate:
        condition: service_completed_successfully  # wait for migration to finish
      db:
        condition: service_healthy
    ports:
      - "8080:8080"

volumes:
  db-data:

5. One off jobs: maintenance tasks on demand

One off jobs differ from init containers in that they do not run automatically when the stack starts, but on demand. A typical example is a database dump, a cache warm up, a data import routine, or running fixtures for test data. These tasks need to run in the context of the already running stack, with access to the same networks and environment variables, but only when explicitly requested.

The tool of choice in Docker Compose for one off jobs is docker compose run --rm. This command starts a new container for the given service, connects it to the stack network, executes the command and then automatically removes the container afterwards (--rm). Unlike docker compose exec, which runs a command inside an already running container, docker compose run starts a completely new container. That matters for jobs that need a defined starting state.

Using profiles in Docker Compose, one off job services can be defined so they do not start on a normal docker compose up but can be invoked via their specific profile. That keeps the default stack configuration clean and avoids job containers accidentally being started permanently.

6. depends_on and healthchecks as startup order

Startup order in Docker Compose is not deterministic without explicit configuration, all services start at the same time. depends_on is the right solution, but in its simplest form it only waits for the dependent container to have started, not for it to be ready. A database container that has started but is not yet initialized will look unavailable to an application that immediately tries to open a database connection.

Healthchecks close that gap. With depends_on: condition: service_healthy, Docker Compose waits until the dependent service's healthcheck passes. The healthcheck must be configured in the dependent container's service definition. For MySQL the healthcheck is mysqladmin ping, for PostgreSQL pg_isready, for Redis redis-cli ping. These commands only signal success once the service is genuinely ready to accept connections.

For sidecar containers and init containers, the conditions service_started, service_healthy and service_completed_successfully together form a powerful toolset. A sidecar container can start with service_started because it tolerates brief interruptions of the main service. An init container must wait for the database with service_healthy. The main service must wait for the init container with service_completed_successfully.

7. restart policies for long lived vs. short lived containers

The restart policy is a frequently underrated configuration parameter in Docker Compose. For sidecar containers meant to run permanently, restart: unless-stopped is the right choice, they get restarted after failures and system reboots but not when stopped manually. For init containers, restart: "no" is mandatory, an init container that restarts after completing successfully would run the migration all over again.

For one off jobs the restart policy is irrelevant, because they are started via docker compose run and the --rm flag removes them automatically once they finish. Sidecar containers with restart: on-failure get restarted on errors but not on a normal exit. That makes sense for sidecars that shut down when the main service is no longer reachable, they should wait and retry rather than keep running forever.

The combination of a restart policy and a depends_on condition defines the complete lifecycle model of a container in the stack. An init container with restart: "no" together with a main service using depends_on: condition: service_completed_successfully forms a reliable, deterministic startup protocol that keeps working correctly even after stack restarts.

8. Pattern comparison: sidecar, utility, init and job

The four patterns differ in lifetime, trigger, purpose and configuration. The table below summarizes the key differences.

Pattern Lifetime Trigger restart policy
Sidecar Permanent (like the main service) Stack start unless-stopped
Utility container Short lived (command duration) Manual invocation no (or --rm)
Init container Once (before the main service) Stack start no
One off job Short lived (job duration) docker compose run no + --rm

The most common confusion is between init containers and one off jobs. An init container is part of the automatic stack start and blocks the main service until it has finished successfully. A one off job is a manual intervention run in the context of the already running stack. In practice the line blurs: an initial database setup is an init container, a later data import is a one off job.

9. Practical example: Magento with an init container and sidecars

A Magento 2 stack with a Hyva theme offers a real world example of all four patterns. The init container runs bin/magento setup:upgrade and setup:di:compile at stack start whenever the Magento version has changed. The sidecar container is a Varnish cache warmer that continuously calls URLs and keeps the Varnish cache warm. A second sidecar container exports Redis metrics for monitoring. One off jobs handle cache flushes, reindexing and database dumps on demand.

The depends_on chain in the Magento stack is longer than in simpler projects: MySQL must be healthy before Redis starts. Redis must be healthy before Elasticsearch starts. Elasticsearch must be healthy before the init container runs the Magento setup. The PHP FPM service waits for the init container to complete successfully. Nginx waits for PHP FPM. The sidecar container for cache warming waits for Nginx. This entire chain can be configured declaratively with depends_on and healthchecks.


# Simplified Magento stack with init container and sidecar
services:
  mysql:
    image: mysql:8.4
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-prootpass"]
      interval: 5s
      retries: 12

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 3s
      retries: 10
    depends_on:
      mysql:
        condition: service_healthy

  # Init container: runs setup:upgrade if needed
  magento-setup:
    image: ghcr.io/myorg/magento:2.4.8
    command: ["bin/magento", "setup:upgrade", "--keep-generated"]
    restart: "no"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

  phpfpm:
    image: ghcr.io/myorg/magento:2.4.8
    restart: unless-stopped
    depends_on:
      magento-setup:
        condition: service_completed_successfully

  # Sidecar: Redis metrics for Prometheus
  redis-exporter:
    image: oliver006/redis_exporter:latest
    restart: unless-stopped
    environment:
      REDIS_ADDR: redis://redis:6379
    depends_on:
      redis:
        condition: service_healthy

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    depends_on:
      phpfpm:
        condition: service_started
    ports:
      - "8080:80"

Mironsoft

Docker Compose stacks, sidecar patterns and container orchestration

Want Docker Compose stacks that start correctly?

We structure Compose stacks with correct startup order, init containers, sidecar patterns and one off jobs, so every stack start is deterministic and reliable.

Compose review

Analyzing startup order, healthchecks, restart policies and service dependencies

Sidecar design

Configuring log shipping, metrics export and cache warming as sidecar containers

Init containers

Modeling database migrations and setup steps as reliable init containers

10. Summary

The four container patterns, sidecar containers, utility containers, init containers and one off jobs, together cover every cross cutting task in a Docker Compose stack. Sidecar containers run permanently alongside the main service and extend its functionality without code changes. Utility containers wrap CLI tools and run on demand. Init containers prepare the stack before the main service starts. One off jobs handle maintenance tasks in the context of the already running stack.

Correctly configuring restart policies, depends_on conditions and healthchecks is essential for these patterns to work reliably. An init container with the wrong restart policy, or without a service_completed_successfully condition on the main service, leads to race conditions at stack start. Investing in correctly configured startup order pays off every single time the stack restarts.

Sidecars, Utility Containers and Jobs: The Essentials at a Glance

Sidecar container

Permanent companion. restart: unless-stopped. Typical: log shipper, metrics exporter, cache warmer. Shares network or volume with the main service.

Init container

Runs once at stack start. restart: "no". Main service uses depends_on condition: service_completed_successfully. For DB migrations and setup steps.

Utility container

On demand via docker compose run --rm. CLI tools in a defined version. No local install effort. Profiles for Compose integration.

depends_on + healthchecks

service_started, service_healthy, service_completed_successfully: the three conditions for precise startup order. Define healthchecks on the dependent service.

11. FAQ: Sidecars, Utility Containers and Jobs

1What is a sidecar container?
Permanent companion to the main service. Shares network/volume. Log shipper, metrics exporter. No changes to main service code.
2Init container vs. one off job?
Init container: automatic at stack start, blocks the main service. One off job: manual via docker compose run, inside the running stack.
3Init container should not restart?
Set restart: "no". Without this setting Docker Compose restarts the init container automatically after it finishes.
4depends_on on init container completion?
condition: service_completed_successfully. Waits for exit code 0, not merely for the container to have started.
5compose run vs. compose exec?
run: new container, fresh state. exec: inside a running container. run for jobs, exec for debugging.
6Sidecar crash affects the main service?
No. Sidecar has no control over the main service process. Main service keeps running. Sidecar with restart: unless-stopped restarts.
7depends_on without a healthcheck not enough?
depends_on service_started only waits for the container to start. Healthcheck signals success only once the service is genuinely ready, e.g. mysql mysqladmin ping.
8Utility container without Compose?
docker run --rm --network projectname_default -v $(pwd):/app composer:2.8 install. Connects to the stack network, is removed automatically once it finishes.
9Keeping a Compose file with many services manageable?
Use profiles: monitoring, tools, debug. docker compose --profile monitoring up starts the core stack plus the monitoring sidecars.
10Multiple init containers?
Yes. Each as a separate service with restart: no. They can depend on one another. Main service waits for the last one in the chain.