# Master tmux: From Multiplexer to a Go Session Manager

- URL: https://karandeepsingh.ca/posts/mastering-tmux-go-session-manager/
- Author: Karandeep Singh
- Published: 2026-02-21
- Series: Linux Commands to Go
- Tags: linux, cli, golang, bash, tmux
- Summary: Learn tmux one concept at a time — sessions, windows, panes, configuration — then build a Go CLI tool that reads a YAML file and launches your entire dev environment automatically.

I've used tmux daily for 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.

![Terminal workstation set up for mastering tmux and building a Go session manager](https://karandeepsingh.ca/images/posts/mastering-tmux-go.jpg)

## Prerequisites

- A Linux or macOS terminal (native, WSL, or SSH to a server)
- tmux installed (`sudo apt install tmux` or `brew 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:

```bash
tmux new -s deploy
```

You're now inside a tmux session called `deploy`. Run something that takes a while:

```bash
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:

```text
$ tmux list-sessions
deploy: 1 windows (created Fri Feb 21 14:00:00 2026)
```

Reattach:

```bash
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.

**info:**
**Nested sessions gotcha:** If you run `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:

```bash
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:

```bash
tmux new -s project
```

You're in window 0. Rename it:

- Press `Ctrl+B ,` — a prompt appears at the bottom
- Type `editor` and press Enter

Create a new window:

- Press `Ctrl+B c` — you're in a new, empty window

Rename it to `server`:

- Press `Ctrl+B ,`, type `server`, press Enter

Create two more:

- `Ctrl+B c`, rename to `logs`
- `Ctrl+B c`, rename to `shell`

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):

```bash
# Press Ctrl+B "
```

Split vertically (left and right):

```bash
# 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.

**warning:**
**The synchronize-panes footgun:** tmux has a feature called `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`:

```bash
# 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:

```bash
tmux source-file ~/.tmux.conf
```

Or if you added the bind above, just press `Ctrl+B r`.

**tip:**
**The mouse tip nobody tells you about:** Run `: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.

**Full .tmux.conf with comments**
<pre><code># ==========================
# 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'
</code></pre>

**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`:

```bash
#!/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:

```bash
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:

```bash
mkdir tmux-dev && cd tmux-dev
go mod init tmux-dev
```

Create `main.go` with a helper function that runs any tmux command:

```go
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:

```text
$ go run main.go
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:

```go
// 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:

```bash
go get gopkg.in/yaml.v3
```

Create a config file `projects/myapp.yaml`:

```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:

```go
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:

```text
$ go build -o tmux-dev .
$ ./tmux-dev projects/myapp.yaml
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:

```yaml
# projects/blog.yaml
session: blog
dir: ~/projects/blog
windows:
  - name: editor
    command: vim content/
  - name: hugo
    command: hugo server -D
  - name: shell
```

```yaml
# 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:

```text
$ ./tmux-dev list
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:

1. **Sessions** — persistent workspaces that survive disconnects
2. **Windows** — named tabs inside a session for organizing tasks
3. **Panes** — side-by-side views within a single window
4. **Configuration** — a minimal `.tmux.conf` with mouse support and vi keys
5. **Bash scripting** — automating environment setup with `send-keys` and `new-window`
6. **Go + tmux** — driving tmux from Go with `exec.Command`
7. **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:**
```bash
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:**
```bash
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:**
```bash
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:**
```bash
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:**
```go
// 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+B` is the prefix — every tmux shortcut starts with it
- `Ctrl+B d` detaches. The session keeps running
- Sessions contain windows. Windows contain panes
- `tmux send-keys` lets 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`/`Stderr` when attaching to sessions from Go

## Keep Reading

- [Containers From Scratch in Go](https://karandeepsingh.ca/posts/containers-from-scratch-go/) — build a container runtime with namespaces, cgroups, and chroot, one step at a time.
- [Log Aggregator From Scratch in Go](https://karandeepsingh.ca/posts/log-aggregator-from-scratch-go/) — tail files with inotify, parse syslog, and output structured JSON.
- [Unix Power Tools Every DevOps Engineer Should Know](https://karandeepsingh.ca/posts/weird-unix-commands/) — pv, xargs, watch, and more underrated commands for production work.

## References and Further Reading

- Hogan, B. P. (2016). [tmux 2: Productive Mouse-Free Development](https://pragprog.com/titles/bhtmux2/tmux-2/). Pragmatic Bookshelf.
- tmux contributors. (2024). [tmux — terminal multiplexer](https://github.com/tmux/tmux). GitHub.
- The Go Authors. (2024). [os/exec package](https://pkg.go.dev/os/exec). Go Documentation.

**Question:** What's your must-have tmux keybinding or config line? The one setting that changed how you work in the terminal?
