Baeldung Pro – Ops – NPI EA (cat = Baeldung on Ops)
announcement - icon

It's finally here:

>> The Road to Membership and Baeldung Pro.

Going into ads, no-ads reading, and bit about how Baeldung works if you're curious :)

Partner – Orkes – NPI EA (cat=Kubernetes)
announcement - icon

Modern software architecture is often broken. Slow delivery leads to missed opportunities, innovation is stalled due to architectural complexities, and engineering resources are exceedingly expensive.

Orkes is the leading workflow orchestration platform built to enable teams to transform the way they develop, connect, and deploy applications, microservices, AI agents, and more.

With Orkes Conductor managed through Orkes Cloud, developers can focus on building mission critical applications without worrying about infrastructure maintenance to meet goals and, simply put, taking new products live faster and reducing total cost of ownership.

Try a 14-Day Free Trial of Orkes Conductor today.

1. Introduction

Post-startup scripts are commands executed on a container once it’s fully started. System administrators use post-startup scripts to set database migrations, seed data, or register the container with service discovery systems.

In this tutorial, we demonstrate how to run a script after a Docker Compose container starts. We tested all commands on Ubuntu 22.04 with Docker version 27.4.0 and Docker Compose version v2.31.0.

2. Startup Script and Compose File

As a compulsory prerequisite, the system we work on should have Docker installed. Now, we proceed with other setup steps.

2.1. Project Structure

To maintain a well-organized demonstration, let’s create a directory named compose-demo to store some demo files:

$ mkdir -p ~/compose-demo
$ cd ~/compose-demo

Now, we should reside within the compose-demo directory. In fact, we assume we’re already on this path in most snippets below.

2.2. Create Script

Then, we create a simple post-start Bash script named startup.sh that sets up a basic Python Web server:

$ cat startup.sh
#!/bin/bash
echo "Starting simple HTTP server..."
python3 -m http.server 8080 &
echo "HTTP server started on port 8080"
tail -f /dev/null

Let’s make it an executable with chmod:

$ chmod +x startup.sh

Thus, we have startup.sh as a runnable script that we can use after the container startup.

2.3. Docker Compose Configuration File

Next, we create a docker-compose.yaml file for the Docker Compose configurations. This file sets up a single service named web and uses the python:3.11-slim-buster lightweight image:

services:
  web:
    image: python:3.11-slim-buster
    ports:
      - "8080:8080"

As seen above, we also mapped the 8080 port on the host machine to the 8080 port of the container so we could access the HTTP server on the host machine. In each case, we assume this basic setup and perform modifications to and around it.

3. Using command With exec

The first approach to running a script after a Docker Compose container starts is executing the script through the command field in the docker-compose.yaml file:

$ cat docker-compose.yaml
services:
  web:
  ...
    command: ["sh", "-c", "/startup.sh && exec \"$@\""]

The above configuration executes the startup.sh script. If successful, the exec command replaces the shell process with the process started by the script. Therefore, this enables the server to function in the foreground.

To ensure this method works, we mount the script to the container as a volume. This way, the container has an up-to-date version of the script even if the container restarts:

$ cat docker-compose.yaml
services:
  web:
  ...
    volumes:
      - ./startup.sh:/startup.sh

To make sure the container is healthy after running the post-start script, we can add a healthcheck to the Docker Compose configuration file:

$ cat docker-compose.yaml
...
services:
  web:
    image: python:3.11-slim-buster
    command: ["sh", "-c", "/startup.sh && exec \"$@\""] 
    volumes:
      - ./startup.sh:/startup.sh
    ports:
      - "8080:8080"
    healthcheck:
      test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080')"]
      interval: 10s
      timeout: 5s
      retries: 3

Now, we can start the container in detached mode:

$ docker-compose up -d 
[+] Running 1/1
 ✔ Container compose-demo-web-1  Started                                  16.7s

After starting the container, we can check the logs to confirm the HTTP server is up:

$ docker-compose logs -f web
web-1  | Starting simple HTTP server...
web-1  | HTTP server started on port 8080
web-1  | 127.0.0.1 - - [24/Dec/2024 11:16:24] "GET / HTTP/1.1" 200 -
...

The log output confirms that the HTTP server started within the container after startup.

4. Entrypoint Override

Another method is overriding the container entrypoint. This approach closely resembles using the script within the container’s command statement, but it designates the script as the default executable for container startup. Unlike command, the entrypoint cannot be altered from the command line.

Let’s modify the compose file to use the startup.sh script as the container’s entrypoint:

$ cat docker-compose.yaml
...
services:
  web:
    image: python:3.11-slim-buster
    entrypoint: ["/startup.sh"]
    volumes:
      - ./startup.sh:/startup.sh
    ports:
      - "8080:8080"

Then, we recreate the container with the new configuration:

$ docker-compose up -d

After creating the container, we can verify the HTTP server startup by using curl to access localhost port 8080, which is mapped to port 8080 of the container:

$ curl localhost:8080
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
...
</html>

As seen above, the output indicates that the HTTP server was started by configuring the entrypoint of the Docker Compose container to execute a post-start script.

5. Using docker-compose exec

This method is similar to using docker exec, except this time we target a running a Docker Compose service.

Hence, we can demonstrate the process by modifying the docker-compose.yaml file entrypoint to keep the container running:

$ cat docker-compose.yaml
...
services:
  web:
    image: python:3.11-slim-buster
    entrypoint: ["tail", "-f", "/dev/null"]
    volumes:
      - ./startup.sh:/startup.sh
    ports:
      - "8080:8080"

Then, we start the service:

$ docker-compose up -d

Once the service is up, we can use docker-compose exec to run the script:

$ docker-compose exec -d web /startup.sh -d

Again, to confirm the HTTP server creation, we can either check the container logs or use curl on localhost port 8080:

$ sudo docker-compose logs -f web
web-1  | Starting simple HTTP server...
web-1  | HTTP server started on port 8080
...

While this method is faster, it’s usually best suited for one-off scripts.

6. Using post_start Lifecycle Hook

Docker Compose introduced service lifecycle hooks in version 2.30.0. These hooks enable us to execute commands at specific stages of a container lifecycle, such as post_start (after the container starts) and pre_stop (before the container stops). However, the post_start hook is the primary focus for the current use case.

One key advantage of lifecycle hooks is that they can operate with elevated privileges, even if the container doesn’t have those permissions. Hence, this allows tasks like setting up configurations or initializing services without compromising the containers security.

Let’s test out this post_start hook by modifying the docker-compose.yaml file:

$ cat docker-compose.yaml
...
services:
  web:
    image: python:3.11-slim-buster
    entrypoint: ["tail", "-f", "/dev/null"]
    ports:
      - "8080:8080"
    volumes:
      - ./startup.sh:/startup.sh
    post_start:
      - command: ["sh", "-c", "/startup.sh"]

As seen above, we define the command used by the post_start hook to execute the startup.sh script mounted on the container.

Additionally, we modify the startup.sh script to remove any long-running commands like tail -f, as the post_start hook throws webhook errors if they are present:

$ cat startup.sh
#!/bin/bash
echo "Starting simple HTTP server..."
python3 -m http.server 8080 &
echo "HTTP server started on port 8080"

Once we’ve modified the script, we can create the container:

$ docker-compose up
[+] Running 2/2
✔ Network compose-demo_default Created 0.1s 
✔ Container compose-demo-web-1 Created 0.1s 
Attaching to web-1
web-1 -> | Starting simple HTTP server...
web-1 -> | HTTP server started on port 8080

The output demonstrates that the script runs successfully using the post_start hook.

7. Conclusion

In this article, we covered different methods to run a script after a Docker Compose container starts using a simple Python HTTP server as an example.

While all of these methods work, they’re applicable in different scenarios. For instance, the entrypoint override method is useful if we want the script to become an integral part of the container lifecycle.

On the other hand, although lifecycle hooks offer a convenient way to manage container events, they are still under development and might not be as stable or widely supported as traditional methods like using the command with exec.