A simple command line tool to prompt and configure docker-compose stacks
Introduction
I manage a lot of small server setups, like one or two servers per location, and I’ve set up these servers using Docker and Docker-compose running on Ubuntu. I generally write scripts to handle everything from creating volumes and environment variables to starting, stopping, and updating the services. For the compose files, I typically split them into sections for different types of environments or configurations (such as FreeIPA master and replica), then use the COMPOSE_FILE
variable to determine what gets included for each setup.
Problem
This approach is very modular and allows differently configured setups to use the same compose files, but it has many drawbacks. Writing .yml files for each volume or writing complex configuration scripts is time-consuming and error-prone. Additionally, if you happened to run a configuration script more than once, it would erase the current .env
file, losing all your settings. For an example, see the mkenv
configuration script for Gitea below.
1#!/bin/bash
2YOUR_BUILD="compose/core.yml"
3echo -n "Enter your domain: "
4read YOUR_DOMAIN
5
6echo -n "Enter your SSH port: "
7read YOUR_SSH_LISTEN_PORT
8
9
10echo "You can use NFS, (abs)olute path or (rel)ative path"
11echo "If you want to mix them create your own compose/custom.yml file"
12PS3="Which do you want to use?: "
13select choice in "NFS" "Absolute Path" "Relative Path"
14do
15 case $choice in
16 "NFS")
17 YOUR_BUILD="${YOUR_BUILD}:compose/nfs.yml"
18 echo -n "Enter NFS path to your Gitea data directory: "
19 read IN
20 IFS=':' read -ra ADDR <<< "$IN"
21 GITEA_DATA_VOL_IP=${ADDR[0]}
22 GITEA_DATA_VOL_DIR=${ADDR[1]}
23
24 echo -n "Enter NFS path to your Gitea database directory: "
25 read IN
26 IFS=':' read -ra ADDR <<< "$IN"
27 GITEA_DATABASE_VOL_IP=${ADDR[0]}
28 GITEA_DATABASE_VOL_DIR=${ADDR[1]}
29
30 break;;
31 "Absolute Path")
32 YOUR_BUILD="${YOUR_BUILD}:compose/local.yml"
33 echo -n "Enter the absolute path to your Gitea data directory: "
34 read GITEA_DATA_VOL_DIR
35
36 echo -n "Enter the absolute path to your Gitea database directory: "
37 read GITEA_DATABASE_VOL_DIR
38
39 break;;
40 "Relative Path")
41 YOUR_BUILD="${YOUR_BUILD}:compose/local.yml"
42 GITEA_DATA_VOL_DIR="\${PWD}/data"
43 echo "Enter your gitea data directory relative to \"${PWD}\""
44 echo "Default location is ${GITEA_DATA_VOL_DIR}"
45 echo -n "Enter your Gitea data directory: "
46 read IN
47 if [ ! -z "$IN" ]; then
48 GITEA_DATA_VOL_DIR="\${PWD}/$IN"
49 fi
50
51 GITEA_DATABASE_VOL_DIR="\${PWD}/database"
52 echo "Enter your gitea database directory relative to \"${PWD}\""
53 echo "Default location is ${GITEA_DATABASE_VOL_DIR}"
54 echo -n "Enter your Gitea database directory: "
55 read IN
56 if [ ! -z "$IN" ]; then
57 GITEA_DATABASE_VOL_DIR="\${PWD}/$IN"
58 fi
59
60 break;;
61 *)
62 echo "Invalid response";;
63 esac
64done
65
66echo -n "Do you want to add your own changes in 'compose/custom.yml'? (y/n): "
67read CUSTOM
68if [ "$CUSTOM" == "y" ] || [ "$CUSTOM" == "Y" ]; then
69 YOUR_BUILD="${YOUR_BUILD}:compose/custom.yml"
70 touch compose/custom.yml
71fi
72
73echo -n "Do you want to prefix the compose setup? (y/n): "
74read IN
75if [ "$IN" == "y" ] || [ "$IN" == "Y" ]; then
76 echo -n "Enter your prefix: "
77 read PREFIX
78 PREFIX="${PREFIX}_"
79fi
80
81echo "Generating database password"
82YOUR_DB_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 22 | head -n 1)
83
84echo "Getting UID"
85YOUR_USER_UID=$(id -u)
86
87echo "Getting GID"
88YOUR_USER_GID=$(id -g)
89
90sed s/YOUR_DB_PASSWORD/$YOUR_DB_PASSWORD/g example.env | \
91sed s/YOUR_USER_UID/"$YOUR_USER_UID"/g |\
92sed s/YOUR_USER_GID/"$YOUR_USER_GID"/g |\
93sed s/YOUR_PREFIX/$PREFIX/g |\
94sed s,YOUR_BUILD,$YOUR_BUILD,g |\
95sed s/YOUR_SSH_LISTEN_PORT/$YOUR_SSH_LISTEN_PORT/g |\
96sed s,YOUR_GITEA_DATA_VOL_IP,$GITEA_DATA_VOL_IP,g |\
97sed s,YOUR_GITEA_DATABASE_VOL_IP,$GITEA_DATABASE_VOL_IP,g |\
98sed s,YOUR_GITEA_DATA_VOL_DIR,$GITEA_DATA_VOL_DIR,g |\
99sed s,YOUR_GITEA_DATABASE_VOL_DIR,$GITEA_DATABASE_VOL_DIR,g |\
100sed s/YOUR_DOMAIN/$YOUR_DOMAIN/g > .env
Solution
To avoid writing long scripts for setting up docker-compose files, I developed a simple tool to read a definition file and an optional .env
file and then ask for what’s missing while still allowing the user to keep existing settings intact. The goals for this are:
Don’t depend on any tools being installed on the server since many scripts require many commands to be present to work
Handle already existing .env
files and don’t lose settings
Use a simple definition language that will take little effort to write
To not depend on local tools, I implemented the tool in Go; this allows a single static binary to run everywhere. For handling existing .env
files, the trick was to read the file into a string map and use these settings as the defaults for each prompt.
Custom DSL
For the definition language, since I figured it would be a simple task, I just defined a simple line-based language as seen below:
1DATABASE_PASSWORD=@:passgen
2PROTOCOL=@:select(tcp,udp)
3UID=@:uid
4GID=@:gid
5ADMIN_NAME=@:text_input
JSON Instead
This simple Domain-specific language (DSL) quickly became a bad idea. It would require more work to implement than I wanted, so I decided to go with JSON, something familiar to most developers, and then add a few functions to generate needed information, such as passwords or user IDs. The final JSON at the time of writing this article looks as seen below:
1{
2 "version": 1,
3 "configurations": {
4 ".env": {
5 "file": ".env",
6 "variables": [
7 {
8 "prompt": "Enter prefix if wanted",
9 "function": "text_input",
10 "defaults": [""],
11 "outputs": ["PREFIX"],
12 "options": [],
13 "affixes": ["", "_"]
14 },
15 {
16 "function": "bool_input",
17 "compose_files": ["compose/core.yml"],
18 "defaults": ["true"]
19 },
20 {
21 "prompt": "would you like to add custom.yml to your compose?",
22 "function": "bool_input",
23 "compose_files": ["compose/custom.yml"],
24 "outputs": ["CUSTOM_COMPOSE_FILE"],
25 "defaults": ["false"]
26 },
27 {
28 "prompt": "Enter domain name",
29 "function": "text_input",
30 "defaults": [""],
31 "outputs": ["DOMAIN"],
32 "options": []
33 },
34 {
35 "prompt": "Compose project name",
36 "function": "text_input",
37 "defaults": ["${PREFIX}gitea"],
38 "outputs": ["COMPOSE_PROJECT_NAME"],
39 "options": []
40 },
41 {
42 "prompt": "Database type",
43 "function": "select_input",
44 "defaults": [],
45 "outputs": ["DB_TYPE"],
46 "options": ["postgres"]
47 },
48 {
49 "prompt": "Database name",
50 "function": "text_input",
51 "defaults": ["gitea"],
52 "outputs": ["POSTGRES_DB", "DB_NAME"],
53 "options": []
54 },
55 {
56 "prompt": "PostgreSQL user",
57 "function": "text_input",
58 "defaults": ["gitea"],
59 "outputs": ["POSTGRES_USER", "DB_USER"],
60 "options": []
61 },
62 {
63 "prompt": "PostgreSQL password",
64 "function": "password_input",
65 "defaults": ["@passgen(30)"],
66 "outputs": ["POSTGRES_PASSWORD", "DB_PASSWD"],
67 "options": []
68 },
69 {
70 "prompt": "Database host",
71 "function": "",
72 "defaults": ["${PREFIX}gitea_database:5432"],
73 "outputs": ["DB_HOST"],
74 "options": []
75 },
76 {
77 "prompt": "Landing page",
78 "function": "select_input",
79 "defaults": [],
80 "outputs": ["LANDING_PAGE"],
81 "options": ["explore"]
82 },
83 {
84 "prompt": "Disable registration",
85 "function": "bool_input",
86 "comments": [
87 "This is used to disable registration on the landing page",
88 "If you set it to false, you will be able to register new users"
89 ],
90 "defaults": ["true"],
91 "outputs": ["DISABLE_REGISTRATION"],
92 "options": []
93 },
94 {
95 "prompt": "SSH port",
96 "function": "int_input",
97 "defaults": ["22222"],
98 "outputs": [
99 "SSH_LISTEN_PORT",
100 "GITEA__server__SSH_PORT",
101 "GITEA__server__SSH_LISTEN_PORT"
102 ],
103 "options": []
104 },
105 {
106 "prompt": "Gitea domain name",
107 "function": "text_input",
108 "defaults": ["gitea.${DOMAIN}"],
109 "outputs": [
110 "GITEA_DOMAIN",
111 "GITEA__server__SSH_DOMAIN",
112 "GITEA__server__DOMAIN"
113 ],
114 "options": []
115 },
116 {
117 "prompt": "Gitea root url",
118 "function": "text_input",
119 "defaults": ["https://gitea.${DOMAIN}"],
120 "outputs": ["GITEA__server__ROOT_URL"],
121 "options": []
122 },
123 {
124 "prompt": "User uid",
125 "function": "id_input",
126 "defaults": ["@uid"],
127 "outputs": ["USER_UID"],
128 "options": []
129 },
130 {
131 "prompt": "User gid",
132 "function": "id_input",
133 "defaults": ["@gid"],
134 "outputs": ["USER_GID"],
135 "options": []
136 },
137 {
138 "prompt": "Data volume",
139 "function": "volume_input",
140 "defaults": ["${PWD}/data", ""],
141 "outputs": ["GITEA_DATA_VOL_DIR", "GITEA_DATA_VOL_IP"],
142 "options": [],
143 "key": "data",
144 "name": "${PREFIX}gitea_data"
145 },
146 {
147 "prompt": "Database volume",
148 "function": "volume_input",
149 "defaults": ["${PWD}/database", ""],
150 "outputs": ["GITEA_DATABASE_VOL_DIR", "GITEA_DATABASE_VOL_IP"],
151 "options": [],
152 "key": "database",
153 "name": "${PREFIX}gitea_database"
154 }
155 ]
156 }
157 }
158}
Done
After switching to JSON and defining how the Schema (as seen above) should look, I finished writing the initial version of docker-prompt and then tested it by porting a few of my docker-compose configurations to its JSON definition language; this worked well and achieved all the goals.
Source code
The source for docker-prompt is available for use on Gitlab under the Mozilla Public License Version 2.0. Contributions are welcome; feel free to create a pull request.