Start Local Services Fast in Zero Lines of Code with Docker Compose
Tags: docker, dockercompose, local, productivity
Published: 2023-01-24 15:11:08

---
title: Start Local Services Fast in Zero Lines of Code with Docker Compose
published: true
date: 2021-01-04 00:00:00 UTC
tags: docker, dockercompose, local, productivity
canonical_url: https://www.rocky.dev/blog/start-local-services-fast-in-zero-lines-of-code-with-docker-compose
---

...unless YAML counts as code.

Many companies have home-grown scripts and tools to start services locally. In my experience, the scripts start as well-intentioned wrappers simplifying common `docker` and `docker-compose` commands. They tend to age poorly, however. They get complex, are a maintenance burden, and force developers to learn the wrapper commands instead of the underlying tool commands they may already be familiar with. They don't wrap each flag or option, making them less flexible. When the tools they wrap update, the tool must change to take advantage of new features. This, in turn, can lead to using deprecated, slow, or less secure commands, impacting the engineering team's ability to deliver value to customers.

Twice I have converted internal tools to use `docker-compose` directly. Using this approach enables,

- [YAML's merge syntax](https://yaml.org/type/merge.html) for sharing common configuration between services
- Showing all services and their dependencies in once place (or multiple, depending on your use-case)
- Quickly iterating on configuration changes
- Relying on robust, community-supported CLI with no maintenance requirements
- Using new features on day one instead having to add support
- Exporting the `COMPOSE_FILE` variable so you can start services from any directory.

We'll walk through an example `docker-compose.yml` file using this approach.

```yaml
version: "2.4"

x-depends-on-postgres: &depends-on-postgres
  postgres: { condition: service_healthy }

x-interval5-retries45-timeout5
  interval: 5s
  retries: 45
  timeout: 5s
```

First, note we use v2.4 syntax instead of v3.x. Think of `docker-compose` v2 and v3 more like a fork than an upgrade, with v2 perfectly capable provided you don't need Docker Swarm support. Ideally, your services are resilient to dependencies being down, allowing you to start every service in parallel. If they aren't, `docker-compose` v2.4 syntax allows using `depends_on` `condition`s and `healthcheck` keys together, allowing you to start services in parallel if they're not dependent on each other and in order if they are. If you're using v3.x, [this page](https://docs.docker.com/compose/startup-order/) gives options to accomplish something similar, they're just more work and, sadly, there's no plan to add the v2 functionality back.

Next, we add common configuration using keys that start with `x-` via [YAML merge key types](https://yaml.org/type/merge.html), which [`docker-compose` supports](https://docs.docker.com/compose/compose-file/compose-file-v2/#extension-fields). This example only has two services so not much is shared, but this can lead to a large reduction in duplication for more realistic scenarios.

```yaml
services:
  my-service:
    depends_on:
      <<: *depends-on-postgres
    environment:
      SERVICE_NAME: my-service
    image: ${MY_SERVICE_REPO:-MY_DEFAULT_REGISTRY}/my-service:latest

  postgres:
    environment:
      PGDATA: /var/lib/postgresql/data/pgdata
      POSTGRES_DATABASE: my-service-data
      POSTGRES_PASSWORD: xxx
      POSTGRES_USER: user
    healthcheck:
      test:
        "res=$$(echo 'SELECT 1' | psql --host localhost --username $$POSTGRES_USER
        --dbname $$POSTGRES_DATABASE --quiet --no-align --tuples-only) &&
        [$$res = '1'] && exit 0 || exit 1"
      <<: *interval5-retries45-timeout5
    image: postgres:12
    ports: [5432:5432]
    volumes:
      - ./data/postgres:/var/lib/postgresql/data/pgdata:delegated
    command: postgres
```

In the `services` section, add each as you normally would. Then, specify the `depends_on` key for any service with dependencies and the `healthcheck` key for any service depended on by another service. To add a healthcheck,

- Many services may share a healthcheck, in that case, pull it into a YAML merge key type to reduce duplication
- For new or one-off services, start by [looking here](https://github.com/docker-library/healthcheck) to see if one exists or for inspiration. Once you have a command you think works, `exec` into the container with `docker-compose exec SERVICE sh` and test it. Some containers won't have `curl` or other tools, so you may need to get creative. Once it's working, add the command to `docker-compose.yml`, start the service, and run `docker-compose ps` to ensure it becomes healthy. If it doesn't, run `docker inspect $(docker-compose ps -q SERVICE) | jq '.[].State.Health'` to see what's being returned, edit the command, and try again.

You'll notice the image for `my-service`'s image is `${MY_SERVICE_REPO:-MY_DEFAULT_REGISTRY}/my-service:latest`. This allows quickly swapping between local and hosted images [using a `.env` file](https://docs.docker.com/compose/environment-variables/). By default, it uses `MY_DEFAULT_REGISTRY`. To use a local image, add `MY_SERVICE_REPO=local` to `.env`.

```yaml
services:
  # ...as shown above

  flow-backend:
    depends_on:
      my-service: { condition: service_healthy }
      # ...other services
    image: rwgrim/docker-noop
```

You'll likely have services you tend to start together. To make this easier, optionally add "flows" at the bottom of `services`. These only exist to start their dependents, so they use the tiny [`rwgrim/docker-noop`](https://hub.docker.com/r/rwgrim/docker-noop) image, which simply exits successfully. In a real application, maybe you'll have `flow-backend`, `flow-frontend`, and then a `flow-all` that depends on both of the previous flows.

Here's the full example,

```yaml
version: "2.4" # v3 doesn't support using depends_on conditions with healthcheck

#############################################################################
# Shared config via merge key types, https://yaml.org/type/merge.html
#############################################################################
x-depends-on-postgres: &depends-on-postgres
  postgres: { condition: service_healthy }

x-interval5-retries45-timeout5
  interval: 5s
  retries: 45
  timeout: 5s

services:
  #############################################################################
  # Services
  #############################################################################
  my-service:
    depends_on:
      <<: *depends-on-postgres
    environment:
      SERVICE_NAME: my-service
    image: ${MY_SERVICE_REPO:-MY_DEFAULT_REGISTRY}/my-service:latest

  postgres:
    environment:
      PGDATA: /var/lib/postgresql/data/pgdata
      POSTGRES_DATABASE: my-service-data
      POSTGRES_PASSWORD: xxx
      POSTGRES_USER: user
    healthcheck:
      test:
        "res=$$(echo 'SELECT 1' | psql --host localhost --username $$POSTGRES_USER
        --dbname $$POSTGRES_DATABASE --quiet --no-align --tuples-only) &&
        [$$res = '1'] && exit 0 || exit 1"
      <<: *interval5-retries45-timeout5
    image: postgres:12
    ports: [5432:5432]
    volumes:
      - ./data/postgres:/var/lib/postgresql/data/pgdata:delegated
    command: postgres

  #############################################################################
  # Flows
  #############################################################################
  flow-backend:
    depends_on:
      my-service: { condition: service_healthy }
      # ...other services
    image: rwgrim/docker-noop
```

For even higher productivity, export `COMPOSE_FILE='path/to/docker-compose.yml'` and create the alias `alias dcp=docker-compose`, allowing you to start services from any directory!

Day-to-day, you'll use `docker-compose` commands directly. Common commands: run `dcp up -d SERVICE_1 SERVICE_2 ...` to create and start the provided services and their dependencies (the `-d` or `--detach` flag runs them in the background), `dcp ps` to list their statuses, `dcp stop` to stop them, `dcp start` to start them back up, and `dcp down` to remove containers, images, and volumes. Run `dcp pull` to download the latest images, `dcp logs SERVICE_1 SERVICE_2` to view logs, `dcp config --services` to validate the `docker-compose.yml` file and list all services defined, and `dcp config --services | grep flow` to list only flows. Run `dcp -h` for all commands.

And that's it! All services configured in one file with minimal duplication that you can run anywhere using the newest features of a community-supported CLI with no code to maintain!