I’ve used tmux every day for the past seven years. Not because I have some elaborate 200-line config or a fancy status bar with weather and stock tickers. I started using it because I was tired of losing SSH sessions, opening twelve terminal tabs, and forgetting which tab had my logs.
tmux solved all of that. Sessions that survive disconnects, windows that organize your work, panes that let you see everything at once. Once you get comfortable with the Ctrl+B combos, the terminal just feels different.
This article walks through tmux one concept at a time. By the end, you’ll build a Go CLI tool that reads a YAML config and launches your entire dev environment β editor, server, logs, shell β in one command.
Prerequisites
- A Linux or macOS terminal (native, WSL, or SSH to a server)
- tmux installed (
sudo apt install tmuxorbrew install tmux) - Go 1.21+ installed (for Steps 6-7)
Step 1: Your First Session (Detach and Reattach)
What: Create a tmux session, run something, disconnect, and come back to it.
Why: Without tmux, closing your terminal kills everything running in it. SSH drops, accidental Ctrl+D, laptop lid close β all of it kills your processes. tmux sessions live on the server. You just reattach.
Create a named session:
tmux new -s deploy
You’re now inside a tmux session called deploy. Run something that takes a while:
ping google.com
Now detach β press Ctrl+B, then d. That’s the tmux prefix (Ctrl+B) followed by the command key (d for detach). You’re back in your original shell. The ping is still running.
Verify it:
tmux list-sessions
Expected output:
deploy: 1 windows (created Fri Feb 21 14:00:00 2026)
Reattach:
tmux attach -t deploy
The ping is still going. Every line that printed while you were away is there in the scrollback. That’s the core value of tmux: your work survives disconnects.
tmux inside an existing tmux session, you get a nested session. The outer session captures Ctrl+B before the inner one ever sees it. You’ll press Ctrl+B d and detach from the outer session instead of the inner one. If this happens, use Ctrl+B Ctrl+B d to send the prefix through to the inner session. Better yet, just don’t nest sessions.Kill the session when you’re done:
tmux kill-session -t deploy
One concept: sessions persist independently of your terminal.
Step 2: Windows (Multiple Tasks, One Session)
What: Create tabs inside a tmux session so you can run multiple things without opening new terminals.
Why: A typical dev workflow has at least four things running: an editor, a dev server, a log tail, and a shell for ad-hoc commands. Without tmux, that’s four terminal tabs. With tmux, it’s four windows in one session that you can switch between instantly.
Create a session and add windows:
tmux new -s project
You’re in window 0. Rename it:
- Press
Ctrl+B ,β a prompt appears at the bottom - Type
editorand press Enter
Create a new window:
- Press
Ctrl+B cβ you’re in a new, empty window
Rename it to server:
- Press
Ctrl+B ,, typeserver, press Enter
Create two more:
Ctrl+B c, rename tologsCtrl+B c, rename toshell
Now switch between them:
| Shortcut | Action |
|---|---|
Ctrl+B n | Next window |
Ctrl+B p | Previous window |
Ctrl+B 0 | Jump to window 0 |
Ctrl+B 1 | Jump to window 1 |
Ctrl+B w | List all windows (interactive picker) |
The status bar at the bottom shows all your windows with an asterisk on the active one:
[project] 0:editor 1:server 2:logs* 3:shell
One concept: windows are named tabs inside a session.
Step 3: Panes (Side-by-Side)
What: Split a window into multiple panes so you can see more than one thing at once.
Why: Sometimes switching windows isn’t enough. You want your server output and your log tail visible at the same time. Panes let you split a single window vertically or horizontally.
From any window, split horizontally (top and bottom):
# Press Ctrl+B "
Split vertically (left and right):
# Press Ctrl+B %
Navigate between panes:
| Shortcut | Action |
|---|---|
Ctrl+B β | Move to pane above |
Ctrl+B β | Move to pane below |
Ctrl+B β | Move to pane left |
Ctrl+B β | Move to pane right |
Ctrl+B z | Zoom current pane (toggle fullscreen) |
Ctrl+B x | Kill current pane |
The zoom toggle (Ctrl+B z) is one of the most useful shortcuts. It makes the current pane fill the entire window, and pressing it again restores the layout. Perfect for when you need to read a full stack trace in a small pane.
synchronize-panes that sends your keystrokes to ALL panes simultaneously. Turn it on with :setw synchronize-panes on. This sounds useful until you type rm -rf in one pane and it executes in all of them. If your panes suddenly start echoing each other, check if synchronize-panes is on. Turn it off with :setw synchronize-panes off.A common layout for a dev session is a large editor pane on top with two smaller panes on the bottom β one for the server and one for logs:
ββββββββββββββββββββββββββββ
β β
β editor β
β β
βββββββββββββββ¬βββββββββββββ€
β server β logs β
βββββββββββββββ΄βββββββββββββ
One concept: panes split a window into visible sections.
Step 4: The .tmux.conf You Actually Need
What: Configure tmux with sensible defaults.
Why: The default tmux config is fine for getting started, but a few tweaks make a big difference. I’ve seen people share 200-line configs with custom status bars and plugin managers. You don’t need that. Ten lines covers it.
Create ~/.tmux.conf:
# Start window numbering at 1 (0 is far from the other keys)
set -g base-index 1
setw -g pane-base-index 1
# Mouse support β scroll, click panes, resize
set -g mouse on
# Increase scrollback buffer
set -g history-limit 10000
# Vi-style copy mode
setw -g mode-keys vi
# Reload config without restarting
bind r source-file ~/.tmux.conf \; display "Config reloaded"
# Better split shortcuts (optional β | and - are more intuitive)
bind | split-window -h
bind - split-window -v
# Don't rename windows automatically
set -g allow-rename off
Reload it without restarting tmux:
tmux source-file ~/.tmux.conf
Or if you added the bind above, just press Ctrl+B r.
:set mouse on inside any tmux session to enable mouse support immediately β no config file needed. You can click to select panes, scroll with your mousewheel, and drag pane borders to resize. I’ve watched people use tmux for years without knowing this exists. Once you try it, you won’t go back.# ==========================
# Sensible tmux defaults
# ==========================
# Start window and pane numbering at 1
set -g base-index 1
setw -g pane-base-index 1
# Mouse support (click panes, scroll, resize)
set -g mouse on
# Scrollback buffer (default is 2000)
set -g history-limit 10000
# Vi-style keys in copy mode
setw -g mode-keys vi
# Reload config with Ctrl+B r
bind r source-file ~/.tmux.conf \; display "Config reloaded"
# Intuitive split shortcuts
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
# New windows open in current directory
bind c new-window -c "#{pane_current_path}"
# Don't rename windows automatically
set -g allow-rename off
# Reduce escape delay (helps with vim)
set -sg escape-time 10
# 256 color support
set -g default-terminal "screen-256color"
# Status bar β clean and minimal
set -g status-style 'bg=#333333 fg=#ffffff'
set -g status-left '[#S] '
set -g status-right '%H:%M'
One concept: a short config with mouse support and vi keys is all you need.
Step 5: Scripting tmux from Bash
What: Automate your dev environment setup with a bash script.
Why: Every morning you open tmux, create a session, make four windows, rename them, cd into your project, start the server, tail the logs. That’s five minutes of repetitive setup. A bash script does it in one command.
Create dev-env.sh:
#!/bin/bash
SESSION="myproject"
# Kill existing session if it exists
tmux kill-session -t $SESSION 2>/dev/null
# Create session with first window named "editor"
tmux new-session -d -s $SESSION -n editor
# Send a command to the editor window
tmux send-keys -t $SESSION:editor "cd ~/projects/myapp && vim ." Enter
# Create and set up the server window
tmux new-window -t $SESSION -n server
tmux send-keys -t $SESSION:server "cd ~/projects/myapp && go run main.go" Enter
# Create and set up the logs window
tmux new-window -t $SESSION -n logs
tmux send-keys -t $SESSION:logs "tail -f /var/log/syslog" Enter
# Create a shell window
tmux new-window -t $SESSION -n shell
tmux send-keys -t $SESSION:shell "cd ~/projects/myapp" Enter
# Select the first window
tmux select-window -t $SESSION:editor
# Attach to the session
tmux attach -t $SESSION
Run it:
chmod +x dev-env.sh
./dev-env.sh
You get a four-window dev environment in under a second. The editor opens in vim, the server starts, logs are tailing, and a shell is ready.
This works. But bash scripts get messy fast once you add conditionals (does the session already exist?), error handling (what if go run fails?), and multiple projects. You end up with a different script for each project, duplicating the same session creation logic everywhere.
That’s where Go comes in. We can build a single tool that reads project configs and creates the right environment every time.
One concept: tmux is fully scriptable from the command line.
Step 6: Go β Execute tmux Commands
What: Use Go’s exec.Command to drive tmux programmatically.
Why: Go gives us proper error handling, structured data, and a single compiled binary that works anywhere tmux is installed. We’re going to build a tool called tmux-dev that replaces all those bash scripts.
Set up the project:
mkdir tmux-dev && cd tmux-dev
go mod init tmux-dev
Create main.go with a helper function that runs any tmux command:
package main
import (
"fmt"
"os"
"os/exec"
"strings"
)
// tmuxCmd runs a tmux command and returns the output
func tmuxCmd(args ...string) (string, error) {
cmd := exec.Command("tmux", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("tmux %s: %s (%w)", strings.Join(args, " "), string(out), err)
}
return strings.TrimSpace(string(out)), nil
}
func main() {
// Create a test session
_, err := tmuxCmd("new-session", "-d", "-s", "test-from-go", "-n", "main")
if err != nil {
fmt.Println("Error creating session:", err)
os.Exit(1)
}
fmt.Println("Created session: test-from-go")
// List sessions
out, err := tmuxCmd("list-sessions")
if err != nil {
fmt.Println("Error listing sessions:", err)
os.Exit(1)
}
fmt.Println("Active sessions:")
fmt.Println(out)
// Clean up
tmuxCmd("kill-session", "-t", "test-from-go")
fmt.Println("Cleaned up test session")
}
Run it:
go run main.go
Expected output:
Created session: test-from-go
Active sessions:
test-from-go: 1 windows (created Fri Feb 21 14:00:00 2026)
Cleaned up test session
Go can create, list, and kill tmux sessions. The tmuxCmd helper wraps every call with error handling β if tmux isn’t installed or a command fails, you get a clear error message instead of a silent failure.
Let’s add a function to check if a session already exists:
// sessionExists checks if a tmux session with the given name is running
func sessionExists(name string) bool {
_, err := tmuxCmd("has-session", "-t", name)
return err == nil
}
This uses tmux has-session, which exits 0 if the session exists and 1 if it doesn’t. We turn that exit code into a boolean.
One concept: Go can drive tmux through exec.Command.
Step 7: Go β Config-Driven Project Launcher
What: Read a YAML config file that defines windows and commands for a project, then create the entire tmux environment automatically.
Why: Instead of writing a bash script per project, you write a YAML file. The Go tool reads it and does the rest. Add a new project? Add a YAML file. Change the layout? Edit the config. No code changes.
First, add the YAML dependency:
go get gopkg.in/yaml.v3
Create a config file projects/myapp.yaml:
session: myapp
dir: ~/projects/myapp
windows:
- name: editor
command: vim .
- name: server
command: go run main.go
- name: logs
command: tail -f /tmp/myapp.log
- name: shell
Now update main.go to parse this config and build the session:
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
type Window struct {
Name string `yaml:"name"`
Command string `yaml:"command"`
}
type Project struct {
Session string `yaml:"session"`
Dir string `yaml:"dir"`
Windows []Window `yaml:"windows"`
}
// tmuxCmd runs a tmux command and returns the output
func tmuxCmd(args ...string) (string, error) {
cmd := exec.Command("tmux", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("tmux %s: %s (%w)", strings.Join(args, " "), string(out), err)
}
return strings.TrimSpace(string(out)), nil
}
// sessionExists checks if a tmux session is already running
func sessionExists(name string) bool {
_, err := tmuxCmd("has-session", "-t", name)
return err == nil
}
// expandHome replaces ~ with the actual home directory
func expandHome(path string) string {
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return path
}
return filepath.Join(home, path[2:])
}
return path
}
// startProject creates a tmux session from a Project config
func startProject(p Project) error {
if sessionExists(p.Session) {
fmt.Printf("Session '%s' already exists. Attaching...\n", p.Session)
return attachSession(p.Session)
}
dir := expandHome(p.Dir)
// Create the session with the first window
if len(p.Windows) == 0 {
return fmt.Errorf("no windows defined for session %s", p.Session)
}
first := p.Windows[0]
_, err := tmuxCmd("new-session", "-d", "-s", p.Session, "-n", first.Name, "-c", dir)
if err != nil {
return fmt.Errorf("create session: %w", err)
}
// Send command to first window if specified
if first.Command != "" {
tmuxCmd("send-keys", "-t", p.Session+":"+first.Name, first.Command, "Enter")
}
// Create remaining windows
for _, w := range p.Windows[1:] {
_, err := tmuxCmd("new-window", "-t", p.Session, "-n", w.Name, "-c", dir)
if err != nil {
return fmt.Errorf("create window %s: %w", w.Name, err)
}
if w.Command != "" {
tmuxCmd("send-keys", "-t", p.Session+":"+w.Name, w.Command, "Enter")
}
}
// Select the first window
tmuxCmd("select-window", "-t", p.Session+":"+p.Windows[0].Name)
fmt.Printf("Session '%s' created with %d windows\n", p.Session, len(p.Windows))
return attachSession(p.Session)
}
// attachSession attaches to a tmux session
func attachSession(name string) error {
cmd := exec.Command("tmux", "attach", "-t", name)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: tmux-dev <config.yaml>")
fmt.Println(" tmux-dev list")
os.Exit(1)
}
if os.Args[1] == "list" {
out, err := tmuxCmd("list-sessions")
if err != nil {
fmt.Println("No active sessions")
return
}
fmt.Println(out)
return
}
configFile := os.Args[1]
data, err := os.ReadFile(configFile)
if err != nil {
fmt.Printf("Error reading config: %v\n", err)
os.Exit(1)
}
var project Project
if err := yaml.Unmarshal(data, &project); err != nil {
fmt.Printf("Error parsing config: %v\n", err)
os.Exit(1)
}
if err := startProject(project); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
}
Build and run:
go build -o tmux-dev .
./tmux-dev projects/myapp.yaml
Expected output:
Session 'myapp' created with 4 windows
You’re now inside a tmux session with four named windows. The editor has vim open, the server is running, logs are tailing, and you have a clean shell. All from a 10-line YAML file.
Add more projects by creating more config files:
# projects/blog.yaml
session: blog
dir: ~/projects/blog
windows:
- name: editor
command: vim content/
- name: hugo
command: hugo server -D
- name: shell
# projects/api.yaml
session: api
dir: ~/projects/api
windows:
- name: code
command: vim .
- name: server
command: go run cmd/server/main.go
- name: tests
command: go test ./... -v
- name: db
command: psql -U postgres mydb
- name: shell
List active sessions:
./tmux-dev list
Expected output:
myapp: 4 windows (created Fri Feb 21 14:00:00 2026)
blog: 3 windows (created Fri Feb 21 14:05:00 2026)
api: 5 windows (created Fri Feb 21 14:10:00 2026)
The entire tool is about 110 lines of Go. No plugins, no framework, just exec.Command and YAML parsing. It does exactly one thing: turn a config file into a tmux session.
One concept: declarative config replaces repetitive scripting.
What We Built
Starting from zero tmux knowledge, we worked through each concept:
- Sessions β persistent workspaces that survive disconnects
- Windows β named tabs inside a session for organizing tasks
- Panes β side-by-side views within a single window
- Configuration β a minimal
.tmux.confwith mouse support and vi keys - Bash scripting β automating environment setup with
send-keysandnew-window - Go + tmux β driving tmux from Go with
exec.Command - Config-driven launcher β a YAML file per project, one command to launch everything
The Go tool replaces a pile of bash scripts with a single binary and a config directory.
Cheat Sheet
Session management:
tmux new -s name # Create named session
tmux attach -t name # Attach to session
tmux kill-session -t name # Kill session
tmux list-sessions # List all sessions
tmux switch -t name # Switch between sessions
Window management:
Ctrl+B c # New window
Ctrl+B , # Rename window
Ctrl+B n # Next window
Ctrl+B p # Previous window
Ctrl+B 0-9 # Jump to window by number
Ctrl+B w # Interactive window list
Ctrl+B & # Kill window
Pane management:
Ctrl+B " # Split horizontal
Ctrl+B % # Split vertical
Ctrl+B arrow # Navigate panes
Ctrl+B z # Toggle zoom
Ctrl+B x # Kill pane
Ctrl+B space # Cycle pane layouts
Scripting patterns:
tmux new-session -d -s name -n window # Create detached
tmux new-window -t session -n name # Add window
tmux send-keys -t session:window "cmd" Enter # Run command
tmux select-window -t session:window # Focus window
Go patterns:
// Run any tmux command
cmd := exec.Command("tmux", "new-session", "-d", "-s", "myapp")
out, err := cmd.CombinedOutput()
// Check if session exists
cmd := exec.Command("tmux", "has-session", "-t", "myapp")
exists := cmd.Run() == nil
// Attach (needs stdin/stdout wired up)
cmd := exec.Command("tmux", "attach", "-t", "myapp")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
Key rules to remember:
Ctrl+Bis the prefix β every tmux shortcut starts with itCtrl+B ddetaches. The session keeps running- Sessions contain windows. Windows contain panes
tmux send-keyslets you script any sequence of keystrokes- Mouse mode (
:set mouse on) enables scroll, click, and resize - In Go, use
CombinedOutput()to capture both stdout and stderr from tmux - Wire up
Stdin/Stdout/Stderrwhen attaching to sessions from Go
Keep Reading
- Containers From Scratch in Go β build a container runtime with namespaces, cgroups, and chroot, one step at a time.
- Log Aggregator From Scratch in Go β tail files with inotify, parse syslog, and output structured JSON.
- Unix Power Tools Every DevOps Engineer Should Know β pv, xargs, watch, and more underrated commands for production work.
References and Further Reading
- Hogan, B. P. (2016). tmux 2: Productive Mouse-Free Development. Pragmatic Bookshelf.
- tmux contributors. (2024). tmux β terminal multiplexer. GitHub.
- The Go Authors. (2024). os/exec package. Go Documentation.
What's your must-have tmux keybinding or config line? The one setting that changed how you work in the terminal?