A terminal recording is a text file, not a video. That is the whole reason to make one. A two minute session lands in well under a kilobyte, replays at full resolution in any terminal, stays greppable, and becomes a GIF only when you actually need one for a README or a ticket. Screen capture software gives you none of that.
Four tools cover this on Linux: the script command that is already installed, asciinema for the recording format everyone else reads, agg for turning those recordings into GIF files, and VHS for scripting a terminal demo that renders the same way every time. This guide covers all four, including the version changes that broke older instructions. Everything below was run on Ubuntu 24.04.4 (kernel 6.8.0-106) in September 2026 with asciinema 3.2.1, agg 1.9.0, VHS 0.12.0 and 0.11.0, and terminalizer 0.12.0.
Which Terminal Recorder to Use
Use asciinema plus agg. It is the only combination here that installs as two static binaries, needs no browser, no Node.js and no X server, and renders a GIF in a tenth of a second. Reach for the others only when their specific strength matters.
| Tool | Use it when | Needs | Makes a GIF |
|---|---|---|---|
script + scriptreplay | You cannot install anything, or you need an auditable plain text log | Nothing, ships with util-linux | No, not directly |
| asciinema + agg | Default choice for documentation, bug reports and README demos | Two static binaries | Yes, via agg |
| VHS | The demo must be scripted and reproducible in CI | ttyd, ffmpeg and a bundled Chromium | Yes, natively |
| terminalizer | Never on a server. Unmaintained since 2024 | Node.js, Electron and an X server | Yes, in theory |
Record a Session With script, No Install Required
Every Linux system already has a terminal recorder. script is part of util-linux, so it is on the box before you log in, which makes it the only option on a hardened host where you cannot add packages. It writes two files: the raw terminal output, and a timing file that lets you replay it at the original speed.
script -q -c "./deploy.sh" --timing=deploy.timing deploy.typescript
Drop the -c flag to record an interactive shell instead, and press Ctrl+D when you are finished. Either way you end up with a pair of very small files:
-rw-rw-r-- 1 ubuntu ubuntu 72 Sep 19 11:13 deploy.timing
-rw-rw-r-- 1 ubuntu ubuntu 437 Sep 19 11:13 deploy.typescript
The timing file is two columns, the delay in seconds and the number of bytes written after that delay. The typescript is the terminal output verbatim, prefixed with a header line naming the command and the start time. Both are plain text, so a recording survives grep, diff and version control without any tooling.
Replay it with scriptreplay. util-linux 2.39 prefers the newer flag names, though the older --timing and --typescript forms still work:
scriptreplay --log-out deploy.typescript --log-timing deploy.timing --divisor 4
--divisor 4 plays back at four times speed, which is what you want when reviewing a long session before turning it into something shareable. --maxdelay 2 caps every pause at two seconds, so the twenty minutes you spent reading a man page mid-session does not end up in the replay.
Because script needs no install, it is also the usual way to log privileged sessions for audit. Putting it behind an SSH ForceCommand, or invoking it from a bastion host’s login shell, captures everything an operator typed into a file they cannot edit. If you are wiring that up, SSH tunnels and jump host access covers the connection side.
Install asciinema on Linux
asciinema is on version 3, a full rewrite in Rust, but most distributions still package version 2. Check what your package manager actually offers before following any guide written against the older release, because the two are not the same program.
| Distribution | Packaged version | Install |
|---|---|---|
| Ubuntu 24.04 LTS | 2.4.0-1 | apt install asciinema |
| Debian 13 (trixie) | 2.4.0-1 | apt install asciinema |
| Fedora 44 | 3.0.0-5.fc44 | dnf install asciinema |
| RHEL 9, Rocky 9, Alma 9 | 2.2.0-4.el9 (EPEL) | dnf install epel-release asciinema |
| RHEL 10, Rocky 10, Alma 10 | Not packaged in EPEL | Download the binary |
| Any distribution | 3.2.1 (current) | Download the binary |
Fedora is the only mainstream distribution shipping version 3 today. On everything else, grab the static binary from the release page. It has no runtime dependencies:
curl -sLo asciinema https://github.com/asciinema/asciinema/releases/download/v3.2.1/asciinema-x86_64-unknown-linux-gnu # https://github.com/asciinema/asciinema/releases
chmod +x asciinema
sudo mv asciinema /usr/local/bin/asciinema
Confirm which one is first on your PATH, since a distro package in /usr/bin and a binary in /usr/local/bin will happily coexist and quietly disagree:
asciinema --version
The version string is the only thing that tells the two generations apart:
asciinema 3.2.1
A musl build is published alongside the glibc one for Alpine and other musl systems, and there are darwin builds if you also work on a Mac.
Record, Replay and Stream With asciinema
Recording drops you into a new shell where everything is captured. Exit the shell to stop:
asciinema rec demo.cast
For a scripted, repeatable capture, pass the command directly and the recording stops when it finishes. --overwrite saves you from a prompt on every re-run, which matters when you are iterating:
asciinema rec demo.cast -c "./deploy.sh" --overwrite
Two flags do most of the work in practice. -i 2 clamps idle time to two seconds so thinking pauses do not bloat the replay, and --window-size 92x14 pins the terminal geometry so the recording does not inherit whatever size your window happened to be. Version 3 also adds -I to capture keystrokes as well as output, and --headless to record without attaching to a terminal at all, which is what you want from a cron job or a CI step.
Play a recording back in place:
asciinema play demo.cast --speed 2
Space pauses and resumes, . steps through events while paused, and ] jumps to the next marker. Pair that with --pause-on-markers and a recording becomes a slide deck you can walk a room through. Version 3 adds a third mode on top of record and play. stream broadcasts the session live over a local HTTP server, so a colleague on the same network can watch a build or a migration as it happens without you sharing a screen:
asciinema stream --local 0.0.0.0:8080
Leave the address off and it binds an ephemeral port on localhost instead. --remote pushes the same stream to an asciinema server for a public URL. This is a much lighter answer to “can you show me what you’re seeing” than a video call, and it pairs well with running the work inside a tmux session so the session survives a dropped connection.
What Changed in the asciicast v3 Format
Version 3 writes a new recording format, and the header is where you can see it immediately. Version 2 declared the terminal size as top level keys:
{"version": 2, "width": 80, "height": 24, "timestamp": 1789815094, "env": {"SHELL": "/bin/bash", "TERM": null}}
Version 3 nests them under term and records the command that was captured:
{"version":3,"term":{"cols":80,"rows":24},"timestamp":1789815109,"command":"./demo.sh","env":{"SHELL":"/bin/bash"}}
The second change is easier to miss and matters more if you parse these files yourself. Version 2 timestamps each event as seconds since the start of the recording, so they climb:
[0.00225, "o", "checking node health\r\n"]
[0.804772, "o", "Linux 6.8.0-106-generic x86_64\r\n"]
[1.607254, "o", " total used free\r\n"]
[2.409436, "o", "/dev/sda1 22G 2.6G 20G 12% /\r\n"]
Version 3 records the interval since the previous event instead, so the numbers stay flat:
[0.001, "o", "checking node health\r\n"]
[0.802, "o", "Linux 6.8.0-106-generic x86_64\r\n"]
[0.803, "o", " total used free\r\n"]
[0.803, "o", "/dev/sda1 22G 2.6G 20G 12% /\r\n"]
Anything reading these files naively will play a v3 recording at the wrong speed. agg is not affected. Version 1.7.0 and version 1.9.0 each rendered v2 and v3 copies of the same session to a six frame, 7.01 second GIF, so you do not need to upgrade agg just because you upgraded asciinema.
error: 2 values required by ‘<FILE> <FILE>…’; only 1 was provided
This is the one command that breaks on upgrade. In version 2, asciinema cat demo.cast printed a recording’s output as plain text, which is how people piped recordings into logs and diffs. Version 3 repurposed cat to concatenate several recordings into one, so a single argument is now an error.
The replacement is convert, which selects the txt format automatically from the output extension:
asciinema convert demo.cast demo.txt
You get the session output with colours and control sequences stripped, the same thing old cat produced:
checking node health
Linux 6.8.0-106-generic x86_64
total used free shared buff/cache available
Mem: 5.8Gi 460Mi 4.2Gi 1.0Mi 1.4Gi 5.3Gi
The same subcommand downgrades a recording when some other tool in your pipeline only understands the older format. Pass the format explicitly, because the default output is v3:
asciinema convert demo.cast legacy.cast -f asciicast-v2
raw is the fourth format, giving you the terminal output including control sequences but with no timing. convert also accepts an HTTP URL as input, so you can pull a recording straight off a server without downloading it first.
Convert a Recording to GIF With agg
agg is the asciinema GIF generator, a separate static binary from the same project. Install it the same way:
curl -sLo agg https://github.com/asciinema/agg/releases/download/v1.9.0/agg-x86_64-unknown-linux-gnu # https://github.com/asciinema/agg/releases
chmod +x agg
sudo mv agg /usr/local/bin/agg
Converting takes two arguments and no configuration:
agg demo.cast demo.gif
A five second recording rendered in a tenth of a second across three consecutive runs and produced a 19,320 byte GIF. Output is deterministic, so the same cast file and flags give a byte identical GIF every time, which is what makes agg safe to run in a documentation build. Here is the full loop, recorded with asciinema and rendered with agg:

Geometry and font size are worth setting explicitly so the GIF is not at the mercy of the terminal you recorded in. --cols and --rows override the recorded size, and the default font size is 16 pixels:
agg demo.cast demo.gif --cols 92 --rows 14 --font-size 15 --idle-time-limit 2
Theme choice is the other lever. agg 1.9.0 ships thirteen values for --theme, well beyond the handful older guides list: asciinema, dracula, github-dark, github-light, kanagawa, kanagawa-dragon, kanagawa-light, monokai, nord, solarized-dark, solarized-light, gruvbox-dark and custom. The palette changes the file size, because a GIF is limited to 256 colours and a busier palette compresses worse:
| Theme | GIF size |
|---|---|
| asciinema | 18,652 bytes |
| github-light | 18,763 bytes |
| monokai | 18,884 bytes |
| dracula | 19,320 bytes |
| gruvbox-dark | 19,515 bytes |
| nord | 19,559 bytes |
| solarized-dark | 20,136 bytes |
The flag that saves the most work is --select, added in recent releases. It renders part of a recording instead of all of it, taking a time range, a percentage, an event index, or a marker you set during recording:
agg demo.cast build.gif --select marker:build..marker:test
That means one long recording can produce several short, focused GIFs without re-recording anything. --speed, --fps-cap (30 by default), --no-loop and --last-frame-duration cover the rest of the playback behaviour.
A recording that already exists as script output can still reach a GIF. Record the replay, then render that:
asciinema rec replay.cast -c "scriptreplay --log-out deploy.typescript --log-timing deploy.timing --divisor 2"
agg replay.cast replay.gif
That produced a 598 byte cast and a 19,375 byte GIF from the original 509 bytes of script output, so an audit log captured months ago on a box with nothing installed can still become a diagram in a postmortem.
Generate Terminal GIFs From a Tape File With VHS
VHS inverts the workflow. Instead of recording yourself and converting the result, you write a .tape script describing what should be typed, and VHS drives a real terminal to produce the GIF. Nobody fluffs a take, the output is reproducible, and the tape file diffs cleanly in review. This is why so many CLI projects use it for the demo at the top of their README.
It needs ffmpeg and ttyd on the host, then the release package:
sudo apt install -y ffmpeg ttyd
curl -sLo vhs.deb https://github.com/charmbracelet/vhs/releases/download/v0.11.0/vhs_0.11.0_amd64.deb # https://github.com/charmbracelet/vhs/releases
sudo dpkg -i vhs.deb
On RHEL, Rocky and AlmaLinux the same release page carries an .rpm, and ffmpeg comes from RPM Fusion there rather than the base repositories. Generate a starter tape to see the syntax:
vhs new demo.tape
A working tape is short. Output names the file, Set lines configure the terminal, and the rest is the script:
Output demo.gif
Set FontSize 16
Set Width 900
Set Height 400
Set Theme "Dracula"
Type "uptime"
Enter
Sleep 900ms
Type "df -h / | tail -1"
Enter
Sleep 1200ms
Render it by running the tape:
vhs demo.tape
VHS echoes the parsed tape as it executes each instruction, which makes a syntax mistake obvious straight away:

Set Framerate, Set TypingSpeed and Set Padding are the knobs worth learning, and Output frames/ alongside the GIF writes every captured PNG to a directory, which is the fastest way to tell whether a failure happened during capture or during encoding. Budget for larger files than agg produces. The two GIFs above are different clips so it is not a like for like test, but the agg one is 38 KB and the VHS one is 256 KB, because VHS screenshots a real browser rendered terminal at a fixed frame rate instead of drawing only the frames where the screen changed. Set Framerate 10 is the first thing to turn down when a tape produces something too heavy to put on a page.
could not start browser: browser exited unexpectedly before its debugging endpoint was ready
VHS drives a headless Chromium that it downloads itself on first run, into ~/.cache/rod. On Ubuntu 24.04 that first run fails, because 24.04 blocks unprivileged user namespaces by default and Chromium cannot build its sandbox. Running the downloaded binary by hand shows the real cause:
FATAL:zygote_host_impl_linux.cc(126)] No usable sandbox! Update your kernel or see
https://chromium.googlesource.com/chromium/src/+/main/docs/linux/suid_sandbox_development.md
for more information on developing with the SUID sandbox.
Confirm the restriction is the one biting you:
sysctl kernel.apparmor_restrict_unprivileged_userns
A value of 1 means AppArmor is blocking the sandbox:
kernel.apparmor_restrict_unprivileged_userns = 1
Open a sysctl drop-in to turn it off persistently:
sudo vim /etc/sysctl.d/60-apparmor-namespace.conf
Add the single line that re-enables unprivileged user namespaces:
kernel.apparmor_restrict_unprivileged_userns = 0
Apply it with sudo sysctl --system and VHS starts its browser. This is a real reduction in confinement, so do it on the build box that renders your documentation, not on a production host.
VHS 0.12.0 prints “Creating demo.gif” and writes nothing
Pin VHS to 0.11.0 for now. Version 0.12.0, released on 9 September 2026, captures every frame correctly, prints the usual completion message, exits with status 0, and produces no output file at all. There is no error to catch, which makes it poisonous in a pipeline.
Adding a frames directory to the tape proves capture is fine. On a tape that wrote no GIF, the directory still filled with 154 PNG files. Tracing the process shows why nothing gets encoded:
strace -f -e trace=execve -o trace.log vhs demo.tape
grep -c ffmpeg trace.log
The count comes back zero. VHS 0.12.0 never executes ffmpeg. Running the identical tape on the same machine after downgrading gives the expected result:
-rw-rw-r-- 1 ubuntu ubuntu 48323 Sep 19 10:58 vhs.gif
This is tracked upstream as issue 787, with several open pull requests pointing at a render context being cancelled before the encoders finish. Until one lands, install the 0.11.0 package and hold it.
Why Terminalizer No Longer Works on a Headless Server
Terminalizer still appears in every list of terminal recorders, so it is worth stating plainly: the project has had no commit since August 2024, carries no GitHub release at all, and on a current headless Linux server its renderer does not work. Recording is fine. Rendering is where it falls apart, in four steps.
Installed globally with npm, the render step tries to write frames inside its own installation directory:
Error: EACCES: permission denied, mkdir '/usr/local/lib/node_modules/terminalizer/render/frames'
Re-running under sudo to get that permission trades one failure for another, because the bundled Electron refuses to run as root:
FATAL:electron_main_delegate.cc(294)] Running as root without --no-sandbox is not supported.
Error: Rendering exited with code null
Giving your own user ownership of the install directory clears both, and exposes the real requirement. Electron wants a display:
ERROR:ozone_platform_x11.cc(240)] Missing X server or $DISPLAY
ERROR:env.cc(255)] The platform failed to initialize. Exiting.
The usual answer is a virtual framebuffer, so sudo apt install xvfb and xvfb-run -a terminalizer render demo. On Ubuntu 24.04 that hung for twelve minutes at zero percent CPU without writing a single frame, then gave up:
ERROR:viz_main_impl.cc(186)] Exiting GPU process due to errors during initialization
Error: ENOENT: no such file or directory, open '/tmp/tmp-15977-dhBH3E7cypHM/0.png'
Four failures and roughly a gigabyte of Node.js and Electron to get nowhere. If you inherited a project that depends on terminalizer, the migration is straightforward: its recordings are YAML and the commands map almost directly onto asciinema rec and agg.
File Size, Render Time and Disk Cost Compared
The same five second session was recorded with each tool on the same Ubuntu 24.04 virtual machine. The recording formats differ by a factor of six before a single frame is rendered:
| Tool | Recording size | GIF render time | Disk cost to install |
|---|---|---|---|
script + scriptreplay | 509 bytes (437 + 72) | Not supported directly | 0, ships with util-linux |
| asciinema 3.2.1 + agg 1.9.0 | 528 bytes (asciicast v3) | 0.10 s | 23 MB (two static binaries) |
| VHS 0.11.0 | Tape script, 157 bytes | 8.2 s | 25 MB packages, plus 533 MB Chromium on first run |
| terminalizer 0.12.0 | 3,270 bytes (YAML) | Never completed | Roughly 1 GB with the Node.js toolchain |
The 533 MB matters more than it looks. VHS downloads its own Chromium build on first run rather than using a system browser, so a container image that renders documentation carries that weight in every layer, and a CI runner pays the download on every cold cache. agg is a single 15 MB binary with no runtime dependencies at all.
Speed matters on the other end. Eighty two times the render time is irrelevant for one GIF and very relevant when a docs build regenerates forty of them on every commit, which is exactly the shape of a documentation as code workflow. If you are choosing tooling for a whole team, the same trade-offs show up across documentation tooling generally.
Keep the Cast File, Not the GIF
Commit the .cast and .tape files to the repository next to the documentation they illustrate, and treat the GIF as a build artifact. A half kilobyte of JSON diffs properly in review, survives a rebrand, and re-renders at any size, theme or frame rate you later decide you want. A 40 KB binary that nobody can regenerate does none of that, and it is the reason so many project READMEs still show a demo of a version that shipped three years ago.
When a recording does need to be a real video rather than a terminal replay, that is the point to reach for OBS Studio instead. Terminal recorders capture text, which is their whole advantage, and it stops being an advantage the moment a browser or a GUI enters the frame.