Demystifying Docker -it: From Physical Teletypes to PTYs


Linux Container

Demystifying Docker’s -it option: From Physical Teletypes to PTYs

Motivation

There are many terms in Linux, such as Teletype, Virtual Console (Virtual TTY), Pseudo Terminal.

I was curious about these terms and concepts when I first encountered the -it option in Docker. At that time, I tried to understand the concepts, but the rabbit hole went deeper than expected, so I just moved on. After that, I just habitually used the -it option when I needed it. I know when to use the option, and that was enough.

Recently, I studied for Certified Kubernetes Administrator (CKA) exam, and it rekindled my curiosity. So I started to dig in and write this article—both as a reference for myself and for others who are curious about the same.

TTY: History and the Abstraction

In the early days of computing, computers were expensive and shared among many users. These Users accessed a central computer via a Teletype, commonly referred to as a Terminal or Console. A Teletype is a physical device consisting of a keyboard for input and a paper roll for output. The Linux kernel was architected around this model, implementing a TTY driver to mediate between user processes and terminal devices.

While modern systems use keyboards and monitors instead of physical teletypes, the TTY interface remains a powerful abstraction. It provides a unified interface for handling I/O and controlling special signals (such as SIGINT via Ctrl+C) in a CLI environment. To maintain this architecture in a contemporary environment, the kernel creates several Virtual TTY devices—typically tty1 through tty6—on boot, represented as character device files under /dev/tty<n> (e.g., /dev/tty1).

When a user process needs to read or write data, it doesn’t communicate with the hardware directly. Instead, it interacts with a kernel buffer accessed via a File Descriptor (FD). This leads us to the core Linux philosophy: “Everything is a file”

Everything is a file

“Everything is a file” is a core philosophy of Linux. All I/O operations are expressed through a file interface. Instead of exposing unique interfaces for every communication channel—whether it’s network traffic, disk writes, inter process communication, or device interaction—the Linux kernel provides the process with a File Descriptor (FD).

Think of an FD as a programming object with its own methods. Since the Linux kernel is written in C, it utilise data structures that act like objects to manage and interact with processes. An FD is essentially an object that supports read() and write() methods. when a process calls these methods on an FD, the kernel routes the request to the appropriate driver or handler, which then delivers the data to the final destination.

Because a process can manage multiple I/O channels simultaneosuly, it tracks them via an FD Table (an array-like data structure). The labels fd0, fd1, and fd2 represent the indicies of this array. By default, every process is initailised with:

  • fd0: Standard Input (stdin)
  • fd1: Standard Output (stdout)
  • fd2: Standard Error (stderr)

This explains common shell redirections like echo "hello, world" > text.txt 2>&1. Technically, > is shorthand for 1>. This command swaps the destination of fd1 from the current FD—commonly a terminal—to text.txt, and then sets fd2 to point to the same FD currently referenced by fd1. Consequently, the echo process write its stdout and stderr to the text.txt file instead of the terminal screen.

Interacting with TTY Devices

Return to TTY: when a user process communicates with a TTY device, it opens a file such as dev/tty1 (for virtual TTY) or /dev/ttyS0 (for Physical Teletype). This operation returns an FD object linked to the TTY device, through which the process perfroms I/O operations.

Depending on the device associated with that FD, the kernel processes data through a proper driver—be it a physical driver, a virtual console driver, or a pseudo-terminal driver. The data is then stored in the kernel buffers and send to its destination. This abstraction allows user processes to interact with a unified interface, regradless of whether the underyling device is a physical teletype, a virtual console, or a modern terminal emulator like Ghostty.

Line Discpline

Kernel buffers are memory space in kernel space that store data before it is delivered to a process or a device. When a process opens a TTY device file, the kernel allocates a Read Buffer and Write Buffer and returns an associated FD. When the process writes to that FD, data is stored in the write buffer before being sent to the destination; when it reads, the kernel moves data from the read buffer to the process’s memory space.

The Line Discipline does more than just relaying raw byte stream; it performs essential processing. Key functions include:

  1. Echo: it writes incoming data from the device to not only read buffer but also write buffer. This is the mechanism when you type a on your keyboard, you immediately see a rendered on your screen.

  2. Signal Handling (e.g., Ctrl+C, Backspace): To a process, Ctrl+C is merely a sequence of raw bytes representing Ctrl and C. The LD recognise this input, transforms it into an Interrupt Signal (SIGINT), and delivers it to the process. Similarly, it handles the backspace key by removing the previous character from the buffer.

  3. Canonical Mode: If every keystroke were sent to a process like bash immediately, we wouldn’t be able to input multi-character commands like ls -al. In its default Canonical Mode, the LD collects data in its own Line Buffer until an Enter key is pressed, at which point the entire line is moved to the Read buffer. Conversely, in Raw Mode, the LD passes data to the read buffer immediately without any transformation or buffering. This behaviour is one of key aspects of understanding how the -it option works in container runtimes.

The input flow can be simplified as follows:

Keyboard events -> Line Discipline -> Line Buffer (Wait for Enter)
-> Enter Key -> Read Buffer -> Process `read()`

Thanks to the LD, processes do not need to implement this complex logic. They can simply rely on the TTY driver and focus on their primary tasks while treating I/O as a simple file interaction.

GUI Environment: Display Server and PTY

Most modern Linux systems operate within a GUI environment or are managed through remote connections like SSH. These scenarios introduce requirements that simple TTYs or Virtual TTYs cannot satisfy with their own. This is where the Pseudo Terminal (PTY) comes in.

When a Linux system boots into a GUI, one of the pre-allocated virtual TTYs (e.g., tty1 ). A Display Server—either Wayland or X11—then executes on this active TTY and takes control. From this point on, the kernel no longer passes raw input events to the TTY driver. Instead, it exposes input events through device files like /dev/input/event<n>, which the display server reads and delivers to the appropriate GUI processes. Rendering is also handled directly by the display server rather than the TTY driver.

However, the kernel continues to monitor keyboard events at a low level. If a user presses a specific key combination to switch the active TTY (e.g., Ctrl+Alt+F1-F9 in GUI), the kernel reclaims control from the display server and switches to the designated TTY.

A Quick Personal Side Note

Feel free to skip this part, this is just a personal experience.

I first truly understood this mechanism while using Linux as my daily driver. I once accidentally locked myself out of the GUI login screen after a few incorrect password attempts. Instead of waiting for the lockout timer or performing a hard reboot, I used the Ctrl+Alt+F3 shortcut to jump into a different TTY.

I logged in via the CLI and followed an FAQ guide to unlock my account. During the process, I even encountered a minor issue with the suggested fix, which I eventually resolved and documented in this discussion. It was a perfect practical lesson: even when the high-level GUI locks you out, the underlying TTY architecture remains a reliable fallback for taking back control of your system.

Why PTY is needed in GUI

To run CLI commandsd in a GUI, we use terminal emulators like Ghostty or iTerm2. However, this setup creates a structural challenge that a standard TTY cannot solve.

In a traditional TTY setup, a user process (like bash) sits on one end, and a kernel driver (the TTY driver) sits on the other end, communicating through a kernel buffer. But a terminal emulator like Ghostty is a also a user process. Due to the memory isolation between user space and kernel space, Ghostty cannot directly access kernel buffers. Like any other user process, it must interact with the kernel through a File Descriptor (FD).

This means we now need two FDs to brdige the gap: one for the shell (bash) and another for the terminal emulator (Ghostty). This is precisely what the Pseudo-TTY (PTY) provides.

The Master-Slave Interaction

The PTY solves this constraint by providing a pair of FDs, known as the Master-Slave pair.

  • Master FD: Held by the terminal emulator (Ghostty).
  • Slave FD: Held by the process running inside (e.g., bash).

The Slave FD behaves exactly like a traditional TTY. From bash’s perspective, it performs write operations on what it perceives as a standard TTY. However, unlike a traditional TTY where the kernel driver automatically sends data to a final destination—either physical teletype or virtual console—the data here simply sits in the kernel buffer. It goes no further on its own.

Instead, the Ghostty process performs a read() operation on the Master FD to pull that data out of the kernel buffer. Ghostty then reads that data from the Master FD and renders it on the screen. This relay by a user-space process is what differentiates a PTY from a physical or virtual TTY.

Following is the simplified data flow in TTY (including virtual) and PTY.

  • TTY: user process (e.g., bash) <-> kernel
  • PTY: user process (e.g., bash) <-> kernel <-> another user process (e.g., Ghostty, sshd)

This mechanism is used whenever a CLI environment needs to be emulated between two user-space processes. For example:

  • SSH: The sshd daemon in a remote server holds the Master FD, while the remote bash session holds the Slave FD. Your commands travel over the network to sshd, which then writes them into the Master FD.
  • Terminal Multiplexer (e.g., tmux): Each split pane or window creates a new PTY pair.

How PTYs are Created

The creation of these FDs involves a special device file called /dev/ptmx (Pseudo-Terminal Multiplexer).

  1. Opening the Multiplexer: When Ghostty starts, it opens /dev/ptmx.

  2. Allocating the Pair: The kernel creates a new set of Read/Write Buffers in kernel space. It returns a Master FD to Ghostty and dynamically creates a corresponding slave device file at /dev/pts/<n> (Pseudo-Terminal Slave).

  3. Security and Isolation: To prevent other processes from snooping on your terminal data, the Master FD is only provided to the process that opened /dev/ptmx. If another process opens /dev/ptmx, the kernel doesn’t give them access to the existing session; instead, it creates an entirely new, isolated PTY pair.

  4. Forking the Shell: Ghostty forks a child process (e.g., bash) and provides it with the path to the allocated slave device file (e.g., /dev/pts/1).

  5. Executing the Shell: The child process opens the assigned /dev/pts/1 and duplicates the resulting FD onto its standard input, output, and error (fd0, fd1, fd2).

From this moment on, bash thinks it’s connected to a real TTY. Whether the underlying device is a physical teletype, a virtual console, or a PTY Master, bash doesn’t care. It simply interacts with the unified TTY interface, while Ghostty handles the Master end—performing I/O and rendering the result on the GUI.

Summary: Data Flow by TTY Type

To wrap up the theory, here is a breakdown of how data flows through the different TTY architectures we’ve discussed.

1. Physical TTY

Input (From Device to Process):

  1. The teletype sends input bytes via a serial connection.

  2. The Serial Driver receives them and passes them to the Line Discipline (LD).

  3. The LD transforms the bytes.

  4. The processed data is stored in the TTY Read Buffer (kernel space).

  5. The process calls read() via its FD; the kernel then copies the data from the buffer to the process’s user-space memory.

Output (From Process to Device):

  1. The process calls write() to the TTY FD.

  2. The LD transforms the bytes.

  3. The kernel stores the processed data in the TTY Write Buffer.

  4. The Serial Driver sends the data to the physical teletype.

2. Virtual TTY

Input (From Keyboard to Process):

  1. The keyboard generates hardware input events.

  2. The VT (Virtual Terminal) Driver sends these events to the active Virtual TTY.

  3. The LD transforms the data.

  4. The data is stored in the TTY Read Buffer.

  5. The process reads the data via its FD.

Output (From Process to Screen):

  1. The process calls write() to the TTY FD.

  2. The LD transforms the data.

  3. The data is stored in the active TTY Write Buffer.

  4. The VT Driver processes the text and renders it to the monitor.

3. Pseudo TTY (PTY)

Input (From Terminal Emulator to Shell):

  1. The terminal emulator (e.g., Ghostty) holds the Master FD; the child process (e.g., bash) holds the Slave FD.

  2. Ghostty calls write() via the Master FD.

  3. The LD transforms the data.

  4. The data is stored in the Slave Read Buffer.

  5. bash calls read() via the Slave FD and receives the input.

Output (From Shell to Emulator):

  1. bash calls write() via the Slave FD.

  2. The LD transforms the data.

  3. The data is stored in the Master Read Buffer.

  4. Ghostty calls read() via the Master FD and renders the output to the GUI.

Running a Container with -it

We have finally reached the core of the initial question: how the -it option actually works. While there are various container runtimes, I will use Docker as the standard for this explanation.

If you run docker exec --help, you will see the following descriptions:

Options:
  -d, --detach               Detached mode: run command in the background
  -i, --interactive          Keep STDIN open even if not attached
  -t, --tty                  Allocate a pseudo-TTY

Let’s analyse how these options function from the PTY perspective. To keep things concrete, we will assume a scenario where you are using Ghostty on your host machine and running docker exec to start a bash shell inside a container.

Case 1: Without --interactive

Without -i, the container process has /dev/null as its stdin (fd0). While stdout (fd1) and stderr (fd2) are connected back to the host terminal via pipes or sockets—allowing you to see the output—you cannot send any input to the process.

host-pty-master (Ghostty)

host-pts-slave

docker-cli
  fd0: host pts slave
  fd1: host pts slave
  fd2: host pts slave

bash (container)
  fd0: /dev/null
  fd1: pipe docker-cli fd1
  fd2: pipe docker-cli fd2

Note: several processes relay bytes between the docker-cli and the bash —dockerd, containerd, containerd-shim—but they are omitted here for clarity.

Case 2: With --interactive (-i)

Adding -i connects the host’s stdin through to the container’s stdin. You can now type input, and it will reach the container’s bash process.

host-pty-master (Ghostty)

host-pts-slave

docker-cli
  fd0: host pts slave
  fd1: host pts slave
  fd2: host pts slave

bash (container)
  fd0: pipe docker-cli's fd0
  fd1: pipe → docker-cli's fd1
  fd2: pipe docker-cli's fd2

However, since there is no TTY interface between docker-cli and bash—-only raw byte pipes—two major issues arise:

  1. No Prompt and No Colors: bash (and many other CLI tools) checks whether its standard streams—stdin (fd0), stdout (fd1), or stderr (fd2)—are connected to a TTY using the isatty() system call. Since -i without -t uses raw pipes instead of a TTY, isatty() returns false for all three. Consequently, bash assumes it is being scripted and hides its prompt (e.g., #, or $), while tools like ls or grep disable color highlighting to avoid cluttering a potential log file with escape codes.

  2. Ctrl+C Handling: If you send Ctrl+C, the Line Discipline on the host pts intercepts it first and sends SIGINT to the foreground process on the host—which is docker-cli. The process inside the container never receives the signal.

For example, if you run docker exec -i <container> sh -c 'while true; do echo hello; done' and hit Ctrl+C to kill the process after a while, the result isn’t what you would expect. Since the host PTY intercepts the Ctrl+C signal, it terminates the docker-cli process. The output stops because the connection between the host-pty and the container is closed. However, the infinite loop continues to run inside the container. You’ll likely notice your CPU usage spike and the cooling fans kicking in—at which point you will need to run docker kill to manually stop the container.

Case 3: With --tty (-t)

Adding -t tells the container runtime to create and allocate a new PTY inside the container. The container’s bash process now has /dev/pts/[n] as its stdin, stdout, and stderr. The container’s PTY master is connected back to the host’s pts slave.

host-pty-master (Ghostty)

host-pts-slave

docker-cli
fd0: host pts slave (not forwarded to container PTY master)
fd1: host pts slave container-pty-master
fd2: host pts slave container-pty-master

container-pty-master

bash (container)
fd0: container pts slave
fd1: container pts slave
fd2: container pts slave

bash now sees a TTY and prints its prompt. However, because -i is not set, docker-cli does not forward your keyboard input to the container’s PTY master. You can see the prompt, but you cannot interact with it.

With --interactive and --tty (-it)

Combining both options completes the pipeline for a fully interactive shell.

host-pty-master (Ghostty)

host-pts-slave

docker-cli
  fd0: host pts slave container-pty-master
  fd1: host pts slave container-pty-master
  fd2: host pts slave container-pty-master

container-pty-master

bash (container)
  fd0: container pts slave
  fd1: container pts slave
  fd2: container pts slave

Input flows: host pts masterhost pts slavecontainer pts mastercontainer pts slave. Output flows in reverse.

The Subtle Detail: Raw Mode

There is one subtlety. The pipeline now contains two PTY master-slave pairs, and each one has its own Line Discipline. If Ctrl+C were processed by the host’s LD, it would send SIGINT to docker-cli, not to the container process. To prevent this, when docker-cli detects the -it combination, it puts the host pts into raw mode—disabling Line Discipline transformations on the host side. Raw bytes flow through to the container PTY master, which runs its own Line Discipline and correctly delivers SIGINT to the process inside the container.

Case 5: With --detach (-d)

When -d is used, docker-cli exits immediately after instructing the daemon to start a container or run a command inside the container. The container process is supervised by containerd-shim, which collects the container’s output and writes it to a log file on the host. Without --detach option, containerd-shim relays its stdout and stderr to parent process, but in detach mode, it only writes to the log file without relaying the output. As a result, there is no connection to any host PTS, so nothing appears in your terminal.

host-pty-master (Ghostty)

host-pts
--- no connection ---
containerd-shim

bash (container)
fd0: /dev/null
fd1: pipe containerd-shim
fd2: pipe containerd-shim

Closing Thoughts

We have explored TTY, PTY, Line Discipline, and File Descriptors to understand exactly what happens under the hood when we run a command in a container with the -it option.

This post was primarily written to organise the knowledge I gained while satisfying my own curiousity, and to serve as a future reference for myself—but I hope it also proves useful for anyone who shares the same questions, and that reading it helps demystify things, even if just a litte.