Scripting Development Environment Setup with tmux

Scripting Development Environment Setup with tmux

Rachel Walker Development Technologies, Programming, Tutorial Leave a Comment

Recently, I found myself in a position that developers often face – setting up a complicated local development environment.

My mission: get 8-10 local services up and running using a variety of technologies to test my code prior to merging to a shared environment. Armed with several outdated READMEs, my terminal, and some dire warnings about which services would likely crash my machine, I dove in.

The following blog is my story of writing scripts that utilize tmux to impose some order on the setup process.

The Linux Box and the Power Outage

This particular stack didn’t start with a complicated setup. It began with a few developers running services on their own devices using one or two technologies. As the project grew, new teams formed. Each team picked its own tech stack and ran its services locally. Some teams created Docker-compose files; others wrote READMEs. Each setup was logical in isolation, and teams were able to start and deploy their services.

I joined an infrastructure team. The code changes we made could, and did, impact the entire stack. To test changes effectively, we needed to run our own services as well as the ones owned by other teams both upstream and downstream. This required going through each team’s setup documentation, resolving places they overlapped or conflicted, adding omissions, and trying to keep everything up to date each time they made a change impacting the setup process.

Everyone was generally in agreement that this process was frustrating, error-prone, and incredibly time-consuming. Cross-team discussions began to occur with the hopes of finding a solution. Each team has its own needs and preferences, so coming to an agreement and working on the changes took time. While those conversations were happening, we still needed to be able to test our code.

Before I joined, two engineers had set up some Linux boxes running most of the stack. Luckily, I was able to get access to one of these, so I didn’t have to immediately try to work through the complex setup.

They used tmux sessions to run the services, which I had never used before. There was a bit of a learning curve with the commands, but after a bit of reading, I was able to SSH into the server, use the existing session, and begin developing.

One morning about a week or so later, I logged in to find a freshly rebooted Linux box with no running services, no tmux session, and no history. There had been a power outage. The team members who had set up the environment were unavailable, and I needed to get things functioning again quickly – I had code to test!

It was just me, the READMEs, and everything I’d documented as I onboarded.

When I realized I needed to start everything from scratch, I knew this was an opportunity to never have to do so again. Since a more permanent solution was in progress, I didn’t want to invest a large amount of time or introduce new tech. I stuck with the tmux that the team was using and scripted the creation of the environment as I set everything up.

The rest of this post follows the process of how I approached this using a simple example project.

tmux Introduction

Before I dive into the specifics, let’s answer a basic question. What is tmux?

According to the official tmux wiki on GitHub:

“tmux is a terminal multiplexer. It lets you switch easily between several programs in one terminal, detach them (they keep running in the background) and reattach them to a different terminal.”

To make things clear, this post is not a comprehensive guide to tmux. Rather, it’s a guide to scripting development environment setup using tmux. If you’re in search of the former, I recommend the ArcoLinux tmux introduction.

For the sake of this post, you will need to have a basic understanding of a few tmux building blocks and commands. Below, I will cover some basic terminology and concepts then will explain commands later on as they are referenced.

As a bonus tidbit, the tmux cheatsheet is a fantastic quick reference source for commands.

tmux Building Blocks

The basic building blocks of tmux relevant to this guide are panes, windows, and sessions.

The pane is the smallest display element in tmux. It hosts exactly one terminal which will take up the full display space.

Windows encapsulate panes. Each window has at least one pane. When there are multiple panes in a window, they are simultaneously visible and can be rearranged to create a visual layout.

Sessions group together windows. A session has at least one window. If there are multiple windows, only one is visible at a time, and the user can switch between them. Windows can be named and are assigned an index (0-based) within the session. Generally, windows are accessed via index rather than by name, but the names are helpful for readability.

Tmux Terminal Window

The session name is on the lower left followed by the windows. The three visible panes are separated by lines.

Commands

In tmux, the command syntax varies depending on if the active terminal is attached to a session.

The commands that are executed outside of a session are prefixed with tmux. These are the ones that I find most useful for scripting, but I also use them regularly directly in the terminal. Some important ones are:

  • tmux ls – list the sessions. This also shows the number of windows associated with the sessions.
  • tmux a -t $sessionName – attach to the session with $sessionName

Some commands can be executed outside of a session and directed to a specific target. This uses the following address syntax:

$sessionName:$windowIndex.$paneIndex

Inside sessions, tmux listens for the prefix ctrl+b  to enter command mode. It doesn’t visually indicate that it is listening. Hitting ctrl+b again will take it out of listening mode. The commands used in this tutorial are:

  • ctrl+b d – detach from the active session
  • ctrl+b $windowIndex – navigate to the window with the given index number 0..9

Creating the Script

With the basics covered, let’s move into our example.

For simplicity, I won’t be using ten dependencies in this post. Instead, I have a simple node project with two services. Each service runs an express server that just returns a 200 OK.

  • Service 1 – localhost:8083/service
  • Service 2 – localhost:8084/service

Very little in this setup is specific to this specific stack, but I did include needing to import dependencies and test that the service was healthy. I will go through the same steps I did while I was creating the script. If you prefer to just skip to the script, it is in Step 5.

Step 1: Grouping Dependencies

Even though I needed to have ten services running, my team only owned three to four of them. Everything else was upstream or downstream and mostly a black box, so my first step was to decide what I cared about for those dependencies that I didn’t own.

I needed them to start successfully. I needed to be able to view the logs for the individual services. I needed to be able to start, stop, and restart them independently. Mostly though, I just wanted them to quietly run without any interaction from me and without creating a lot of session names for me to manage and remember.

I decided to organize them into functional sessions with a window for each service. I gave each window a descriptive name so that I could locate them but needing to use the index for reference was not a burden.

For example, the Postgres database, the Java Server, and a few other supporting services went into a session named “backend” that contained three windows, 0:bash, 1:postgres, and 2:java.

I avoided using periods, spaces, and other special characters as I went so that I wouldn’t have to figure out how to manage them later.

In the services I was actively working in, I had much more interaction with the consoles. I needed to be able to start, stop, and restart often. I was also sometimes switching to my IDE, and running them from the inline consoles there rather than through tmux. Because I had so much interaction, I created an individual session for each of these so that I could access them by name.

I didn’t include any panes in my scripting. Sometimes, when I was attached to a session, I created them ad hoc for things like interacting with multiple services running in docker, but I didn’t do this often enough to work through the details of scripting it.

Step 2: Creating the Scripts

Since I was rebuilding my dependency structure from scratch, I didn’t want to just start writing out commands in a script. I needed to check each service to ensure it was healthy before moving on to the next one, so I opened up two terminals.

In one terminal, I ran commands in interactive mode. In the other, I edited a bash script and ran it as I went along. I took the time to look up the syntax as I went and verified that it did the same thing as my manual steps.

This methodology helped make sure I did not skip steps and prompted me to add appropriate dependencies, wait times, and health checks as I went along since I could see how long things were taking and which failures were occurring in interactive mode.

It also resulted in me hardcoding a lot of things in the script, which I went back to and replaced with variables later on.

Step 3: Creating a Session

To begin with the first service, in the interactive terminal, I navigated to the project directory. Then, began by creating a new session, named express: tmux new-session -s express.

This command automatically attached the terminal so I ran the base commands to install the project:

nvm use v16
npm install

I switched over to the bash file and added:

. ~/.nvm/nvm.sh

tmux new-session -d -s script-express
tmux send-keys -t script-express 'nvm use 16' ENTER
tmux send-keys -t script-express 'npm install' ENTER

The new session command is the same, aside from running it in detached mode. The send-keys command is one of the most powerful commands in tmux for scripting. It sends key presses to a pane. It matches key escape characters, such as enter – in this case, the nvm and npm commands are sent to the default pane in the default window of the express session, and enter is pressed after each.

Step 4: Creating Windows

Now, I needed to add the windows. I tend to leave the default window of the session as bash and create new ones as I go since this gives me a landing pad to execute commands within the session. However, that’s a preference, not a requirement.

In my interactive session, I create a new window, which automatically attaches. Then, I rename it to service1:

ctrl+b c
ctrl+b ,

tmux opens input mode at the bottom, and I rename it from bash to service1. This window is at index 1 since the default 0:bash is still present.

Then, I start the service:

node lib/service1

Because node is running in the console, I can’t enter input in this window without stopping it. So I navigate back to the default window with ctrl+b 0.

In the default window, I run the following to make sure everything is healthy.

curl --request GET --url http://localhost:8083/service

It is, so I navigate back to the service1 window and stop the node service. Then, I switch back to the bash script. I can create the window in a single line.

tmux new-window -d -t script-express -n service1 'node lib/service1'

This is creating a new window in detached mode, with the target session of script-express, a name of service1, and also executing the shell command to start the service. I’d also like to make sure that this service starts up correctly, but nothing has a startup dependency on it, so if it doesn’t, I will just log it out and then take a look.

if [ $(curl --head -X GET --retry 5 --retry-all-errors --retry-delay 1 --silent -f \
    -o /dev/null -w '%{http_code}' http://localhost:8083/service) != '200' ]
then
    echo 'service 1 is unhealthy'
fi

When there is an error in the command itself, tmux does not report that out – it just fails to create the window. So, in this case, if I make a typo, such as putting ‘lib/service’ instead of ‘service1,’ I wouldn’t get anything in the console. However, if I do tmux ls, only 1 window shows on the console for the session.

At this point, in the interactive console, I had stopped the service, so I thought it would be a good idea to go ahead and make a cleanup script, too. I started a new bash file for the teardown.

#!/bin/bash

tmux send-keys -t script-express:service1 C-c
tmux kill-session -t script-express

Since service 2 is the exact same as service 1, I just repeat these steps again. This time, however, the window is at 2:service2, and I listen at port 8084. I also make sure to add it to the tear-down.

Step 5: Summary

Now, I have my script. I removed the script prefix since I can use express as my unique session name. I can set up and tear down my environment with a single command. I can add additional services here, or put them in separate scripts. I can also write scripts that will restart services by sending keys to their specific target.

Setup:

#!/bin/bash
. ~/.nvm/nvm.sh

tmux new-session -d -s express
tmux send-keys -t express 'nvm use 16' ENTER
tmux send-keys -t express 'npm install' ENTER

tmux new-window -d -t express -n service1 'node lib/service1'

if [ $(curl --head -X GET --retry 5 --retry-all-errors --retry-delay 1 --silent -f \
    -o /dev/null -w '%{http_code}' http://localhost:8083/service) != '200' ]
then
    echo 'service 1 is unhealthy'
fi

tmux new-window -d -t express -n service2 'node lib/service2'
if [ $(curl --head -X GET --retry 5 --retry-all-errors --retry-delay 1 --silent -f \
    -o /dev/null -w '%{http_code}' http://localhost:8084/service) != '200' ]
then
    echo 'service 2 is unhealthy'
fi

Teardown:

#!/bin/bash

tmux send-keys -t express:service2 C-c
tmux send-keys -t express:service1 C-c
tmux kill-session -t express

Conclusion

Using tmux this way saved me a lot of time during development. I was able to write scripts very quickly to control specific services, and I could always navigate through the attached consoles when things went wrong. When the power went out or the server crashed, I was able to start everything up extremely quickly. I was able to use them on my local machine as well as the original Linux box.

However, there were some problems, and for the sake of transparency, I’ll share them with you.

First, I had to manually install all of the dependencies. This was unpleasant and non-trivial, given the number of technologies present in the stack.

Second, I crashed my computer fairly often. Because there are a lot of detached processes running, it is easy to forget that they are there and to leave them on while doing other computationally intensive processing or overnight.

Third, we experienced a lot of “it works on my machine” when I shared the scripts with other team members. People rarely have the same directory structures or dependency locations. I was on an Ubuntu system and most of the team was running MacBooks with M1 chips, so we did run into some difficulties around Docker specifically.

I don’t think these problems are really specific to tmux – they are symptoms of the larger issue that scripting is rarely the best option for setting up development environments used across multiple ecosystems. Adding flags gets complicated quickly. Technologies change and have to be updated.

Scripting the development environment can be a valuable step while working on a more robust solution such as adopting environment as code or local container orchestration. It lists all of the dependencies far more reliably than a README. Since it is used daily by developers, it is more likely to be kept up-to-date than a file that is referenced when a new person starts. It can also be checked into source control. These sorts of scripts can also be used to provide a template for a more permanent solution.

I’m certainly not the first person who has used tmux this way. tmuxinator is a project that allows YAML templating to define tmux sessions, windows, and panels. This wasn’t the right fit for me since I didn’t want to introduce anything new into the stack, and I knew a more permanent replacement would be coming. However, it may work better than scripts for configurations that need to be shared across the team and persist for a long time. Consider it for your unique situations.

Overall, these scripts were a very helpful tool for a more reliable development setup, but I look forward to their retirement. I am glad that I was introduced to tmux; it worked very well for the ecosystem I was working in. I will continue to use it when I am working with unreliable connections or many terminals, and I recommend checking it out!

If you do decide to check it out, let me know what you think and how you used it in the comments below. I’m always learning. And if you enjoyed this post, be sure to check out the many more on the Keyhole Dev Blog.

5 1 vote
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments