How I created a simple blue/green deployment using only Docker Compose and GitHub Actions

As a solo entrepreneur, I try to keep my infrastructure as simple and manageable as possible. Let’s review together how I’ve integrated a zero downtime deployment on Primfeed.

I use Docker Compose for my environments, and one recurring issue I faced with the production was the downtime when I’m updating the app and running docker compose up -d. It would often take between 15 seconds to 1 minute in some cases, and it was not acceptable for users to have the app down for so long.

The blue/green deployment strategy was the solution, but everything I read online suggested to use Docker Swarm or Kubernetes. I don’t know these stacks, and I don’t want to complicate my current setup even though I know these tools are designed for this.

For several days, I tried to find an article on how to create a blue/green deployment with only Docker Compose, but I’m either bad at Googling, or no such blog post exists for my use case. For years, I have been using open-source projects, and it’s time for me to give back and explain how I created a simple blue/green deployment using only Docker Compose and GitHub Actions.

What is the blue/green deployment?

The blue/green deployment is a strategy to reduce downtime and risk during application updates. It works by running two identical environments, blue (the live version) and green (the new version).

https://candost.blog/the-blue-green-deployment-strategy

When a new version is ready, it’s deployed to the green environment. After verifying it works correctly, the incoming requests are switched from blue to green.

https://candost.blog/the-blue-green-deployment-strategy

And on the next deployment, we’re going from green to blue, etc.

Since I’ve implemented this, I can push multiple new versions of my app and users never notice the switch between the versions.

How does it work?

All deployment steps will be done using GitHub Actions. A new release on our repository will trigger a job that builds our Docker image, connect to the remote server, and update the containers.

Primfeed is based on Dunglas’ symfony-docker repository, but I’ve modified it to match a production application and its needed services: It supports cron tasks, asynchronous messages using Symfony Messenger, SQL Migrations on each deployment, and environment variables shared via GitHub Actions.

The infrastructure is using Traefik for the reverse proxy. Traefik uses a labels system, it’s easy to set up, and it will route our incoming requests to the correct containers.

💡 The complete files will be available in GitHub Gists at the end of this article.

Setting up GitHub Actions

We need two things: The PROD_DEPLOY_COLOR variable used to switch between blue and green, and a token that allows the CI to change this variable.

For the variable, go to your repository settings, under Secrets and variables, click Actions. Choose the tab Variables, then click New repository variable. Create a new variable named PROD_DEPLOY_COLOR and set its value to blue.

To create the token necessary for the CI to change the variable between blue and green, visit https://github.com/settings/personal-access-tokens and generate your Fine-grained token. Set the desired expiration date, and set the access to Read and Write for the Variables.

Once you have your token, add a new secret to your repository: GH_TOKEN_SET_VARIABLES with the token you just created.

Preparing the Docker Compose files

We will use the Extensions from Docker Compose to share the config between our multiple containers.

x-env: &env
    APP_VERSION: ${APP_VERSION}
    DATABASE_URL: ${DATABASE_URL}
    # ... your other envs

x-php-app: &php-app
    image: ghcr.io/username/repository:${APP_VERSION}
    restart: unless-stopped
    healthcheck:
        test: ["CMD", "sh", "-c", "! [ -f /run/force-unhealthy ] && curl -fsS http://localhost:2019/metrics"]
        interval: 5s
        timeout: 5s
        retries: 3
        start_period: 60s
    labels:
        - "traefik.enable=true"
        - "traefik.http.routers.app.rule=Host(`your-domain.com`)"
        - "traefik.http.routers.app.entrypoints=websecure"
        - "traefik.http.routers.app.tls=true"
        - "traefik.http.routers.app.tls.certresolver=le"
        - "traefik.http.services.app.loadbalancer.server.port=80"

services:
    php-green:
        <<: *php-app
        environment:
            CONTAINER_COLOR: green
            <<: *env

    php-blue:
        <<: *php-app
        environment:
            CONTAINER_COLOR: blue
            <<: *env

This is the edited docker-compose.prod.yml I’m using. It contains:

  • The blue and green containers.
  • The extensions for our environnements variables and our base config for the PHP.

I will explain what’s the /run/force-unhealthly is for in the CMD later in this article, but I’m sure the name gives a hint!

Using GitHub Actions

The first step we need is to build our image. This can be done using the official actions from Docker. We enable the cache to speed up the process for the next release, and we properly tag the image to the latest. I personally tag the image with the version name as well, but it’s a personal perference.

jobs:
  build-image:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      packages: write
    steps:
      - name: 📦 Checkout repository
        uses: actions/checkout@v4

      - name: 🐳 Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: 🐳 Log in to the Container registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GH_TOKEN }}

      - name: 🐳 Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: 🐳 Build and push production image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          target: frankenphp_prod
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod-buildcache
          cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod-buildcache,mode=max

When the image is built we can use it on the remote server. The first step before the deployment is to get the current color, and deduce the next color. We will use your token for this action.

jobs:
  deploy:
    needs: build-image
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: 🎨 Deployment colors
        env:
          GH_TOKEN: ${{ secrets.GH_TOKEN_SET_VARIABLES }}
        run: |
          CURRENT=$(gh variable get PROD_DEPLOY_COLOR --repo $GITHUB_REPOSITORY --json value -q .value)
          echo "CURRENT_DEPLOY_COLOR=$CURRENT" >> $GITHUB_ENV
          
          if [ "$CURRENT" = "blue" ]; then
              echo "NEXT_DEPLOY_COLOR=green" >> $GITHUB_ENV
          else
              echo "NEXT_DEPLOY_COLOR=blue" >> $GITHUB_ENV
          fi

Then, let’s deploy the new color to the remote server. The key here is to use the custom CMD we setup in the docker-compose.prod.yml file.

Once the next color is healthy, we force the current color to become unhealthly using an empty file. We create an empty file /run/force-unhealthy and after ~15 seconds (depending on your retries strategy), the container become unhealthy.

- name: 🚀 Deploy API
  uses: appleboy/[email protected]
  with:
    host: your-remote-ip
    username: your-username
    key: ${{ secrets.SSH_KEY_HOST_API }}
    envs: APP_ENV,DATABASE_URL
    script: |
      set -e

      cd ${{ env.PROJECT_PATH }}
      git pull

      echo "🧙 --- Pull the new release"
      docker compose -f docker-compose.yaml -f docker-compose.prod.yaml pull

      echo "🧙 --- php-${{ env.NEXT_DEPLOY_COLOR }} is starting..."
      docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d --no-build php-${{ env.NEXT_DEPLOY_COLOR }}

      echo "🧙 --- Getting container IDs"
      NEXT_DEPLOY_COLOR_CONTAINER_ID=$(docker compose -f docker-compose.yaml -f docker-compose.prod.yaml ps -q php-${{ env.NEXT_DEPLOY_COLOR }})
      CURRENT_DEPLOY_COLOR_CONTAINER_ID=$(docker compose -f docker-compose.yaml -f docker-compose.prod.yaml ps -q php-${{ env.CURRENT_DEPLOY_COLOR }})

      echo "⚙️ --- php-${{ env.CURRENT_DEPLOY_COLOR }}: $CURRENT_DEPLOY_COLOR_CONTAINER_ID"
      echo "⚙️ --- php-${{ env.NEXT_DEPLOY_COLOR }}: $NEXT_DEPLOY_COLOR_CONTAINER_ID"

      echo "🧙 --- Waiting for php-${{ env.NEXT_DEPLOY_COLOR }} to become healthy..."
      while [ "$(docker inspect --format='{{.State.Health.Status}}' "$NEXT_DEPLOY_COLOR_CONTAINER_ID")" != "healthy" ]; do
        sleep 1
      done
      echo "🧙 --- php-${{ env.NEXT_DEPLOY_COLOR }} is healthy."
      
      echo "🧙 --- Forcing php-${{ env.CURRENT_DEPLOY_COLOR }} to become unhealthy."
      docker compose -f docker-compose.yaml -f docker-compose.prod.yaml exec php-${{ env.CURRENT_DEPLOY_COLOR }} touch /run/force-unhealthy

      echo "🧙 --- Waiting for php-${{ env.CURRENT_DEPLOY_COLOR }} to be unhealthy, it should takes ~15s."
      while [ "$(docker inspect --format='{{.State.Health.Status}}' "$CURRENT_DEPLOY_COLOR_CONTAINER_ID")" = "healthy" ]; do
        sleep 1
      done

      echo "🧙 --- php-${{ env.CURRENT_DEPLOY_COLOR }} is unhealthy. Waiting 10s for Traefik to stop sending requests there."
      sleep 10

      echo "🧙 --- Stop gracefully php-${{ env.CURRENT_DEPLOY_COLOR }}. Will timeout after 90s."
      docker compose -f docker-compose.yaml -f docker-compose.prod.yaml stop -t 90 php-${{ env.CURRENT_DEPLOY_COLOR }}

Finally, let’s set the new color as the current in our GitHub repository variables.

-   name: 🎨 Set color to ${{ env.NEXT_DEPLOY_COLOR }}
    env:
        GH_TOKEN: ${{ secrets.GH_TOKEN_SET_VARIABLES }}
    run: |
        gh variable set PROD_DEPLOY_COLOR --repo $GITHUB_REPOSITORY --body "${NEXT_DEPLOY_COLOR}"

Traefik is doing a lot of work for us

When containers have the same Traefik labels, Traefik will split the requests between the healthy containers and this part is important because this is what we use at our advantage.

With GitHub Actions, we force the current color to go unhealthy which will cause Traefik to only send new requests to our new color. After waiting a few seconds to be safe, we can gracefully stop the current color container.

Something to keep in mind here is the timeout when we execute the command docker compose stop. By default, after 10 seconds Docker Compose will force kill the container. In our case, since users may still upload pictures, we override this limit to 90 seconds with docker compose stop -t 90. Once all requests have been processed on the current color container, it will stop.

Disclaimer

This solution is not the best since tools like Docker Swarm and Kubernetes exists and can handle all of that.

As I explained at the beginning of this article, I needed to solve the downtime for every update I made on my app and I didn’t want to change my tools or the way it was setup just yet.

‼️ Lastly, if you’re going into the blue/green deployment, ALWAYS make sure that your old and new code are compatible, as well as your old and new database schema. Otherwise your users will experience downtime.

Bonus – Links

As a bonus, I’m sharing the different files that I use to build and deploy my complete stack. Based on dunglas/symfony-docker, it adds the cron tasks, SQL migrations and asynchronous workers.

You can find the Gist containing the files here.

Enable cron in your containers

To enable cron, add the following to your Dockerfile

COPY frankenphp/crontab.txt /etc/cron.d/app-cron.txt
RUN /usr/bin/crontab /etc/cron.d/app-cron.txt

Add the line in your docker-entrypoint.sh

service cron start

Finally, for each cron task, make sure to add a check on the /run/cron-enabled file that will be removed/added during the deployment.

* * * * * [ -f /run/cron-enabled ] && /usr/local/bin/php /app/bin/console app:scheduled_posts:post -vv >/dev/stdout 2>&1