DEV Community 👩‍💻👨‍💻

Cover image for Local development "environments" per Git branches with Docker Compose
Nándor Holozsnyák
Nándor Holozsnyák

Posted on • Updated on

Local development "environments" per Git branches with Docker Compose

What are Git Hooks?

Have you ever wondered what are the Git hooks and how can they help you be more "effective & productive" ?

Git Hooks are basically "scripts" that can run when specific events are being triggered, like you are making a commit, or you are receiving commits from the remote. There are two types of hooks, client-side and server-side. In this blog post we are going to discover one of the client-side hooks named post-checkout. These hooks must be present in the .git/hooks folder or your project, but it can be changed with the git config core.hooksPath command. If you are interested in the basics please check out it this page: https://git-scm.com/docs/githooks

There are projects where the commit messages are being validated via a specific hook to make sure the messages will be having the "conventional commit" style.

Javascript developers are probably familiar with a tool called Husky it lets you create Git hooks easily installed for the developers. In this blog post we are going to use a Maven project with a Maven plugin that will automatically install hooks into your .git repository.

What is Docker Compose?

With the help of the Docker Compose we are able to create so called YAML files, to create and manage containers "quickly". We can commit these files to our repositories and other developers are able to set up their dependencies easily when they join a project. When I talk about dependencies I mean databases, messaging services, other services (like Spring based apps that are being containerized).

For this Docker and of course the Docker Compose executables must be installed.

An example with Postgres database - the file named by default must be: docker-compose.yml and in the directory the following command must be invoked to start the Postgres container: docker-compose up

version: '3.7'
services: 
  postgres-db:
    image: postgres:14
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: example
      POSTGRES_PASSWORD: example-password
Enter fullscreen mode Exit fullscreen mode

After starting the container (docker-compose up) similar logs should appear:

Creating docker-env-with-git-hooks_postgres-db_1 ... done
Attaching to docker-env-with-git-hooks_postgres-db_1
postgres-db_1  | The files belonging to this database system will be owned by user "postgres".
postgres-db_1  | This user must also own the server process.
postgres-db_1  | 
postgres-db_1  | The database cluster will be initialized with locale "en_US.utf8".
postgres-db_1  | The default database encoding has accordingly been set to "UTF8".
postgres-db_1  | The default text search configuration will be set to "english".
postgres-db_1  | 
postgres-db_1  | Data page checksums are disabled.
...
Enter fullscreen mode Exit fullscreen mode

In another terminal if we execute the docker ps command we should see similar output:

docker ps
CONTAINER ID   IMAGE        COMMAND                  CREATED          STATUS           PORTS                                    NAMES
17adc0b1b905   postgres:14  "docker-entrypoint.s…"   13 minutes ago   Up 13 minutes   0.0.0.0:5432->5432/tcp, :::5432->5432/tcp docker-env-with-git-hooks_postgres-db_1
Enter fullscreen mode Exit fullscreen mode

For me the container ID is 17adc0b1b905 but on your machine it is probably different, most of the columns are self-explanatory but right now I would like to focus on the NAMES column, and the name for our container is: docker-env-with-git-hooks_postgres-db_1. We did not specify any names, so it needs some explanation.

Let's break down the name: docker-env-with-git-hooks_postgres-db_1:

  • docker-env-with-git-hooks - in my case it is the name of the folder where the docker-compose.yml is - this can be overwritten with a flag when the docker-compose is being called
  • postgres-db - this is the name of the service we specified in the docker-compose.yml file - this can be overwritten with a container_name attribute in the docker-compose.yml file, but it will overwrite the whole name, nothing else will alternate it.
  • 1 - this is replica number of the container, we can scale containers and in those cases their postfix numbers are changing - if we specify the container_name attribute for the specific service the number postfix will be gone.

To stop the running container just press Ctrl+C in the terminal window where the containers are running or open another terminal window and go to the folder where the docker-compose.yml file resided and inveok the docker-compose stop command.

Let's try to play around with the docker-compose command and overwrite the folder's name in the container name to awesome-hooks.

Run the following command to get more info on the options: docker-compose --help

...
Options:
  -f, --file FILE             Specify an alternate compose file
                              (default: docker-compose.yml)
  -p, --project-name NAME     Specify an alternate project name
                              (default: directory name)
  --profile NAME              Specify a profile to enable
  -c, --context NAME          Specify a context name
  --verbose                   Show more output
  --log-level LEVEL           Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
  --ansi (never|always|auto)  Control when to print ANSI control characters
  --no-ansi                   Do not print ANSI control characters (DEPRECATED)
  -v, --version               Print version and exit
  -H, --host HOST             Daemon socket to connect to

  --tls                       Use TLS; implied by --tlsverify
  --tlscacert CA_PATH         Trust certs signed only by this CA
  --tlscert CLIENT_CERT_PATH  Path to TLS certificate file
  --tlskey TLS_KEY_PATH       Path to TLS key file
  --tlsverify                 Use TLS and verify the remote
  --skip-hostname-check       Don't check the daemon's hostname against the
                              name specified in the client certificate
  --project-directory PATH    Specify an alternate working directory
                              (default: the path of the Compose file)
  --compatibility             If set, Compose will attempt to convert keys
                              in v3 files to their non-Swarm equivalent (DEPRECATED)
  --env-file PATH             Specify an alternate environment file
...
Enter fullscreen mode Exit fullscreen mode

This option key is the important for us:

  -p, --project-name NAME     Specify an alternate project name
                              (default: directory name)
Enter fullscreen mode Exit fullscreen mode

Knowing that we are able to change the name for the whole docker-compose project: docker-compose --project-name awesome-hooks up

Creating awesome-hooks_postgres-db_1 ... done
Attaching to awesome-hooks_postgres-db_1
postgres-db_1  | The files belonging to this database system will be owned by user "postgres".
postgres-db_1  | This user must also own the server process.
Enter fullscreen mode Exit fullscreen mode

Now let's check the running containers with docker ps.

CONTAINER ID   IMAGE       COMMAND                  CREATED          STATUS        PORTS                                     NAMES
d9601ab32d6d   postgres:14 "docker-entrypoint.s…"   11 seconds ago   Up 10 seconds 0.0.0.0:5432->5432/tcp, :::5432->5432/tcp awesome-hooks_postgres-db_1
Enter fullscreen mode Exit fullscreen mode

The name of the container now is awesome-hooks_postgres-db_1, so the awesome-hooks is now used as the prefix. AWESOME.

Just to make sure to write it down as well, all Docker objects (containers, networks, volumes etc.) are having that specific prefixes, not only the containers.

Combine Docker-Compose with Git Hooks

Think about it, you are in a middle of a big feature, you have already made changes to the application's database schema, you added new tables, new columns (that are not nullable), and you have written tons of code and then BOOM.

A new message arrives: Hey, can you check this pull request and write some feedback? - Yes of course! Let me see it.

Tons of changes in the source code and in the database schema, but you are a good developer, you always check out the code, and you always run the newly created tests, IT tests whatever...

You also know that you have created test data for your new development by hand, and you do not want to lose it, but if you check out and recreate your database you can lose all your "temporary" test data.

There are a tons of good approaches to this kind of situation, like creating a snapshot of your database, or renaming the container and the attached volumes, but it takes time, and you are in a "hurry".

How about creating new containers per git branches? When a branch is being checked out, a new docker-compose project would be launched, the other containers would be stopped, and new containers would be created where the docker-compose project's name would include the git branch name.

For example, a docker-compose file with the previous Postgres service on a branch named feature/ABC-1123-User-profile-management.

Name our project Warp and in this case the container name could be: warp-feature/ABC-1123-User-profile-management_postgres-db_1 and if the earlier containers are stopped, there will be no collisions with the ports, all your previous data would be retained in the other container and your "work" will not be lost.

Demo

Let's create a folder and in that a new git repository with the git init command.
Create a docker-compose file with the following content as before, make sure to stop the previously created containers.

version: '3.7'
services:
  postgres-db:
    image: postgres:14
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: example
      POSTGRES_PASSWORD: example-password
Enter fullscreen mode Exit fullscreen mode

Add this file to the staging area and commit it:

git add docker-compose.yml
git commit -m "Initial commit"
Enter fullscreen mode Exit fullscreen mode
[master (root-commit) 4a99ad5] Initial commit
Enter fullscreen mode Exit fullscreen mode

We would like to create a script, that will be copied to the git hook's directory, and it will be launched at every branch checkout.

The script must be made with the name post-checkout and it must be copied to the .git/hooks folder, and execute permission must be given to it, so invoke chmod +x .git/hooks/post-checkout

From the official documentation we can see that 3 parameters are going to given to that upon execution: https://git-scm.com/docs/githooks#_post_checkout

  • $1 - Previous HEAD
  • $2 - New HEAD
  • $3 - 1 if checking out a branch, 0 if checking out something else, such as a file (rollbacks)

So we are going to receive the SHA values of the HEAD commit, rather than the branch names we are looking forward.

Let's add some code into the shell script:

#!/usr/bin/env bash

echo "Previous head:" $1
echo "Current head:" $2
echo "Branch checkout:" $3

Enter fullscreen mode Exit fullscreen mode

After that create a new branch with the checkout or switch command: git checkout -b test-branch

Switched to a new branch 'test-branch'
Previous head: 4a99ad545a28e22b5774ae2fa90040fdc86e4b7a
Current head: 4a99ad545a28e22b5774ae2fa90040fdc86e4b7a
Branch checkout: 1
Enter fullscreen mode Exit fullscreen mode

We can immediately see one of the problem, if we would use those variables for the project name it would be super confusing, these SHA values are meaning nothing to the developer, and they can be the same if you start a new branch, so we have to come up with a better solution.

With the following command you can get the name of the current branch: git branch --show-current (Available in Git 2.22.0) - For earlier versions it is tricky, but this must be working: git rev-parse --abbrev-ref HEAD

test-branch
Enter fullscreen mode Exit fullscreen mode

Cool we are able to determine the branch we are on, but how can we identify the branch we are coming from?

So in theory we would like to STOP the containers from the branch we are switching from and START new containers.

I would like to propose two ways:

  • We use the git name-rev --name-only <sha> command to retrieve it from the $1 argument
  • We save the current branch name (in this case the previous branch) to a file named .git/earlier-branch, and then we would use it in the process
#!/usr/bin/env bash
if [ ! -f ".git/earlier-branch" ]; then
    echo "File: .git/earlier-branch does not exist, creating with current branch"
    echo EARLIER_BRANCH=$(git branch --show-current) > .git/earlier-branch
fi

echo "Loading: .git/earlier-branch"
source .git/earlier-branch
UPCOMING_BRANCH=$(git branch --show-current)

echo "Before switch branch: $EARLIER_BRANCH - Upcoming branch: $UPCOMING_BRANCH"
echo EARLIER_BRANCH=$(git branch --show-current) > .git/earlier-branch
Enter fullscreen mode Exit fullscreen mode

This snippet on the first launch will create the file that will have a variable named EARLIER_BRANCH having value of the $(git branch --show-current) result. It may fail on the first try, but after it will be relatively consistent.

Okay, the 3rd parameter describes if this operation is a branch change or just a file checkout, so if it's value is 1 (one) it means it is a branch "change", we have to check it.

Let's add the docker-compose commands into the script.

#!/usr/bin/env bash
echo "Post checkout starting"

#
# Args passed to this are:
# $1 - Previous HEAD
# $2 - New HEAD
# $3 - 1 if checking out a branch, 0 if checking out something else, such as a file (rollbacks)
#
if [ '1' == $3 ]
then
    if [ ! -f ".git/earlier-branch" ]; then
        echo "File: .git/earlier-branch does not exist, creating with current branch"
        echo EARLIER_BRANCH=$(git branch --show-current) > .git/earlier-branch
    fi

    echo "Loading: .git/earlier-branch"
    source .git/earlier-branch
    UPCOMING_BRANCH=$(git branch --show-current)

    echo "Before switch branch: $EARLIER_BRANCH - Upcoming branch: $UPCOMING_BRANCH"

    docker-compose --project-name=warp-$EARLIER_BRANCH stop
    docker-compose --project-name=warp-$UPCOMING_BRANCH up -d

    echo EARLIER_BRANCH=$(git branch --show-current) > .git/earlier-branch
fi
Enter fullscreen mode Exit fullscreen mode

We are on the test-branch now, let's go back to the master: git checkout master

Switched to branch 'master'
Post checkout starting
File: .git/earlier-branch does not exist, creating with current branch
Loading: .git/earlier-branch
Before switch branch: master - Upcoming branch: master
Creating warp-master_postgres-db_1 ... done
Enter fullscreen mode Exit fullscreen mode

We can see the interference in the power: "Before switch branch: master - Upcoming branch: master" - This is not true, and this is the problem with this branch resolving strategy, the very first time it will not work, because the .git/earlier-branch file does not exist, but it can be created by hand on the clone, or with some other script.

Now if we would run the docker ps we should see the following:

CONTAINER ID   IMAGE        COMMAND                  CREATED         STATUS                 PORTS                                     NAMES
674ca666fe1c   postgres:14  "docker-entrypoint.s…"   2 minutes ago   Up About a minute      0.0.0.0:5432->5432/tcp, :::5432->5432/tcp warp-master_postgres-db_1
Enter fullscreen mode Exit fullscreen mode

The name of the container is warp-master_postgres-db_1, which we wanted! Yes!!!

Now let's switch back to the test-branch or create a brand new one, like we mentioned earlier above: git checkout -b feature/ABC-1123-User-profile-management

Switched to a new branch 'feature/ABC-1123-User-profile-management'
Post checkout starting
Loading: .git/earlier-branch
Before switch branch: master - Upcoming branch: feature/ABC-1123-User-profile-management
Stopping warp-master_postgres-db_1 ... done
Creating network "warp-featureabc-1123-user-profile-management_default" with the default driver
Creating warp-featureabc-1123-user-profile-management_postgres-db_1 ... done
Enter fullscreen mode Exit fullscreen mode

We can see that the warp-master_postgres-db_1 container is being stopped, and after that the warp-featureabc-1123-user-profile-management_postgres-db_1 container is being created. Awesome! This is what we wanted.

Improve the experience

Me personally if I check out a project and I see a docker-compose.yml file I check it and start it from the IntelliJ IDEA after I imported the project. But there are other people who prefer to start it from the terminal with the docker-compose executable, which is totally understandable. With these methods the created git hook will not work properly so I propose to create a few helper scripts which can be used from the terminal and the hook also can depend on it.

Let's create a start-infrastructure.sh that will contain the following:

#!/usr/bin/env bash

GIT_BRANCH=${1:-$(git branch --show-current)}
NAME="warp-$GIT_BRANCH"
echo "Starting docker compose project with name: $NAME"
docker-compose --project-name=$NAME up -d
Enter fullscreen mode Exit fullscreen mode

And let's create a stop-infrastructure.sh that will contain the following lines:

#!/usr/bin/env bash

GIT_BRANCH=${1:-$(git branch --show-current)}
NAME="warp-$GIT_BRANCH"
echo "Stopping docker compose project with name: $NAME"
docker-compose --project-name=$NAME stop
Enter fullscreen mode Exit fullscreen mode

These scripts can be used to start and stop the containers, and they can be parametrized, but if no parameters are given to it, the current git branch will be used.

Let's rework the git hook:

#!/usr/bin/env bash
echo "Post checkout starting"

#
# Args passed to this are:
# $1 - Previous HEAD
# $2 - New HEAD
# $3 - 1 if checking out a branch, 0 if checking out something else, such as a file (rollbacks)
#
if [ '1' == $3 ]
then
    if [ ! -f ".git/earlier-branch" ]; then
        echo "File: .git/earlier-branch does not exist, creating with current branch"
        echo EARLIER_BRANCH=$(git branch --show-current) > .git/earlier-branch
    fi

    echo "Loading: .git/earlier-branch"
    source .git/earlier-branch
    UPCOMING_BRANCH=$(git branch --show-current)

    echo "Before switch branch: $EARLIER_BRANCH - Upcoming branch: $UPCOMING_BRANCH"

    ./stop-infrastructure.sh $EARLIER_BRANCH # Changed lines
    ./start-infrastructure.sh $UPCOMING_BRANCH  # Changed lines

    echo EARLIER_BRANCH=$(git branch --show-current) > .git/earlier-branch
fi
Enter fullscreen mode Exit fullscreen mode

Check all containers

If we run docker ps -a we are going to see all the containers we created, so we can easily verify that the containers are still there and if we want to just restart any we can do it:

CONTAINER ID   IMAGE        COMMAND                  CREATED        STATUS                      PORTS    NAMES
188995a19b3f   postgres:14  "docker-entrypoint.s…"   5 days ago     Exited (0) 10 minutes ago            docker-env-with-git-hooks_postgres-db_1
5d01690861ac   postgres:14  "docker-entrypoint.s…"   7 days ago     Exited (0) 10 minutes ago            warp-featureabc-1123-user-profile-management_postgres-db_1
674ca666fe1c   postgres:14  "docker-entrypoint.s…"   7 days ago     Exited (0) 10 minutes ago            warp-master_postgres-db_1
e8b534a4bcd9   postgres:14  "docker-entrypoint.s…"   7 days ago     Exited (0) 10 minutes ago            awesome-hooks_postgres-db_1

Enter fullscreen mode Exit fullscreen mode

Combine it with Maven

To make sure every developer on the project is having the same boost for their workflows we can use a Maven plugin to copy this script to their .git/hooks folder.

Create a folder named hooks and put the post-checkout file into, after that configure the following Maven plugin in your project:

<build>
        <plugins>
            <plugin>
                <groupId>com.rudikershaw.gitbuildhook</groupId>
                <artifactId>git-build-hook-maven-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <installHooks>
                        <post-checkout>hooks/post-checkout</post-checkout>
                    </installHooks>
                </configuration>
                <executions>
                    <execution>
                        <id>install-hooks</id>
                        <phase>initialize</phase>
                        <goals>
                            <goal>install</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
</build>
Enter fullscreen mode Exit fullscreen mode

To make sure that the created scripts are going to be executable for other developers as well, we have to add the execution permission to all via a Git command (but first add them to the staging area):

  • git add hooks/post-checkout start-infrastructure.sh stop-infrastructure.sh
  • git update-index --chmod=+x hooks/post-checkout
  • git update-index --chmod=+x start-infrastructure.sh
  • git update-index --chmod=+x stop-infrastructure.sh

Conclusion

Maybe this was a "long" journey, but I hope you liked it, I'm still experimenting with this setup, because you may not want to create a new environment for all of your new branches, but if that is the case, I think it is a good start.

One more addition could be introduced, when a local branch is being deleted maybe a cleanup script could be executed to delete the "dangling" docker-compose projects.

I'm curious about your opinion on the topic, have you tried to do the same? Do you know a better/more professional approach to the problem? If yes please let me know down in the comments.

If you want to follow me you can do it on the following places:

Creator of the cover image: https://unsplash.com/@carrier_lost

Top comments (0)

Dream Big


Use any Linode offering to create something unique or silly in the DEV x Linode Hackathon 2022 and win the Wacky Wildcard category.

Join the Hackathon <-