
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 :)
Last updated: January 2, 2025
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.
As a compulsory prerequisite, the system we work on should have Docker installed. Now, we proceed with other setup steps.
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.
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.
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.
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.
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.
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.
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.
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.