from Ctrl-Z to a clean detach from the terminal
Job control is far more than Ctrl-Z followed by a quick bg. Understanding how Bash manages jobs, process groups and signals lets you run long migrations, backups and maintenance tasks in the background without a dropped SSH session dragging all that progress down with it.
Table of Contents
- 1. What job control in Bash really means
- 2. Viewing and referencing jobs: jobs and %n
- 3. Switching foreground and background: fg and bg
- 4. Ctrl-Z, SIGTSTP and SIGCONT in detail
- 5. disown: detaching jobs from the shell without killing them
- 6. nohup, disown and setsid compared
- 7. Job control in non-interactive scripts
- 8. Common mistakes with job control
- 9. Job control tools compared
- 10. Summary
- 11. FAQ
1. What job control in Bash really means
Job control is the ability of an interactive shell to start multiple processes at once, switch between them, and let them keep running independently of the current terminal. Anyone who starts a long backup in an SSH session, pauses it with Ctrl-Z, resumes it in the background with bg and then closes the connection is using job control, usually without knowing the mechanics behind it. Yet those mechanics decide whether the process survives the disconnect or dies along with the session.
Internally, Bash keeps a job table for every interactive session. Every background process it starts gets a job number, a process group ID and a status: running, stopped or done. Job control is therefore its own subsystem built on top of POSIX process groups and terminal control, not just a loose collection of commands. Only once you understand that fg, bg and disown all operate on the same underlying data structure do the subtleties in the next sections make sense.
In scripts, job control is disabled by default because non-interactive shells have no terminal control of their own. That explains why commands such as fg or bg fail in a script started via cron unless set -m is set explicitly. For administrators planning production maintenance windows, understanding this boundary between interactive and non-interactive job control is the first step toward robust workflows.
2. Viewing and referencing jobs: jobs and %n
The jobs command lists every active background and stopped process of the current shell, each with a job number, status and the original command. This overview is the starting point for any job control operation, because without the job number no process can be pulled into the foreground or background specifically. jobs -l additionally shows the process ID, which matters when combining job control with kill or disown -h.
Jobs are referenced with %n, where n is the job number from the table. %+ or %% always points to the most recently active job, %- to the second most recent. These shorthand forms are a core part of job control syntax and save you from constantly looking up the full job number. A job can also be referenced by a substring of its start command, such as %rsync for the most recently started job whose command begins with rsync.
# Start three background jobs to inspect job control state
sleep 300 &
rsync -av /data/ backup-host:/data/ &
tail -f /var/log/syslog &
jobs -l
# [1] 12345 Running sleep 300 &
# [2]- 12346 Running rsync -av /data/ backup-host:/data/ &
# [3]+ 12347 Running tail -f /var/log/syslog &
# Reference jobs by number, by +/- shortcut, or by command prefix
fg %1 # bring job 1 to foreground
kill %rsync # send SIGTERM to the job whose command starts with rsync
wait %2 # block until job 2 finishes, capture its exit code
The + and minus markers in jobs output are not just cosmetic. They determine which job is targeted when fg or bg is called without an argument. Anyone working regularly with several parallel background processes should get in the habit of always using the explicit job number, since the current job can change as soon as another one finishes or is brought to the foreground.
3. Switching foreground and background: fg and bg
fg brings a stopped or backgrounded job into the foreground and reconnects it to the terminal, making its input and output visible again. bg resumes a stopped job but keeps it running in the background, without blocking the shell. These two commands are the operational side of job control, while jobs only covers the observation side.
A common scenario: an interactive command runs longer than expected, you press Ctrl-Z to pause it, check its status with jobs, and then send it into the background with bg %1. The shell is immediately available for new input again while the process keeps running unaffected. This pattern of job control commands replaces, in many cases, the need to anticipate the delay and append & at the original invocation.
# Start a long-running compression job in the foreground
tar -czf backup.tar.gz /var/www
# Press Ctrl-Z to suspend it, then check the job table
# ^Z
# [1]+ Stopped tar -czf backup.tar.gz /var/www
jobs
# [1]+ Stopped tar -czf backup.tar.gz /var/www
# Resume it in the background, freeing up the shell
bg %1
# [1]+ tar -czf backup.tar.gz /var/www &
# Later, bring it back to the foreground to watch it finish
fg %1
One detail many people miss: bg only works on stopped jobs, not on ones that already finished. Calling bg on a job that has already exited returns an error. Likewise, calling fg on a job that is already running in the foreground has no meaningful effect, because job control only knows one foreground process per terminal at a time. These edge cases are rarely documented but cause plenty of confusion for beginners in practice.
4. Ctrl-Z, SIGTSTP and SIGCONT in detail
Behind Ctrl-Z is the SIGTSTP signal, which the terminal driver sends to the current foreground process group. SIGTSTP is fundamentally different from SIGTERM or SIGKILL: it does not terminate the process, it moves it into a stopped state from which it can be resumed at any time. Job control is built entirely on this stop-and-resume mechanism, not on killing and restarting processes.
A stopped process is resumed via SIGCONT, which both fg and bg send internally to the process group. The only difference between the two commands is whether the process group gets terminal control back afterward (fg) or not (bg). This interplay of SIGTSTP and SIGCONT is exactly what sets job control apart from simple process management, since it allows an entire process group, including all child processes, to be paused and resumed seamlessly.
A process can also make itself immune to SIGTSTP, for example database servers that do not want to be interrupted mid commit. For administrators this means Ctrl-Z is not a guaranteed stop, it is a request the target process can ignore. This nuance matters especially when job control is used to steer long-running database migrations.
5. disown: detaching jobs from the shell without killing them
disown removes a job from the current shell's job table without terminating the process itself. This is the crucial difference from kill: the process keeps running unchanged, but the shell loses all control of and knowledge about it. Within job control, disown is the tool for deliberately decoupling a background process from the lifecycle of the current session before you close that session.
Without disown, every background job receives SIGHUP by default when the interactive shell exits, provided huponexit is set or the connection is actually dropped. SIGHUP terminates the process in most cases immediately, because it rarely implements handling for it. disown -h marks a job specifically so it is exempt from SIGHUP, while it remains visible in the job table, a useful middle ground between a full disown and no protection at all.
# Start a long migration job that must survive session disconnect
./run-migration.sh &
migration_pid=$!
# Fully detach the job from the shell's job table
disown %1
# Alternative: keep it visible in "jobs" but exempt from SIGHUP
# disown -h %1
# Verify detachment: the job no longer appears in the job table
jobs
# (no output for the disowned job)
# The process still exists at the OS level and can be checked directly
ps -p "$migration_pid" -o pid,ppid,stat,cmd
disown without an argument affects the current job, disown -a affects all jobs of the session, disown -r only running ones, not stopped ones. These variants are especially useful in maintenance scripts that start several background processes at once, all of which should survive an SSH session being cut. Job control combined with targeted disown replaces reaching for tmux or screen in such cases, when the extra complexity of a terminal multiplexer is not needed.
6. nohup, disown and setsid compared
nohup, disown and setsid solve a similar problem at different levels, and choosing the right tool depends on the exact need. nohup is prefixed at the start of a command and makes the new process ignore SIGHUP from the very beginning, regardless of whether it ever shows up in the shell's job control table. disown, in contrast, works retroactively on an already running job and changes its visibility within the shell itself.
setsid goes one step further: it starts the process in a completely new session with its own process group ID, detached from the controlling terminal from the start. While disown only cleans up Bash's internal job table, setsid severs the connection at the kernel level. For true daemon processes meant to run permanently and independently of the starting terminal, setsid is therefore more robust than plain job control with disown, because even reattaching a terminal has no effect anymore.
# nohup: ignore SIGHUP from the very start, redirect output explicitly
nohup ./long-report.sh > report.log 2>&1 &
# disown: detach an already-running job control entry after the fact
./long-report.sh &
disown -h %1
# setsid: fully detach into a new session, independent of any terminal
setsid ./long-report.sh > report.log 2>&1 < /dev/null &
# Combine both for maximum robustness against terminal loss
setsid nohup ./long-report.sh > report.log 2>&1 < /dev/null &
7. Job control in non-interactive scripts
By default, job control is disabled in non-interactive Bash scripts, even if the script itself starts background processes with &. The jobs command usually still works, but fg and bg fail with the message no job control in this shell. Anyone who explicitly needs interactive job control semantics in a script must set set -m at the top, which places every background process in its own process group.
In most automation scripts, however, set -m is not the right approach, since interactively switching between jobs is rarely the goal. Instead, it is usually enough to capture PIDs with $! and wait on them explicitly, a technique explored further in the companion article on wait -n. The actual goal in scripts is usually not job control in the interactive sense, but reliable process management without terminal binding.
#!/usr/bin/env bash
set -euo pipefail
# Job control is off by default in non-interactive scripts.
# "fg"/"bg" would fail here without "set -m" first.
./worker-a.sh &
pid_a=$!
./worker-b.sh &
pid_b=$!
# The script-friendly equivalent of job control: track PIDs, wait explicitly
wait "$pid_a"
echo "worker-a exit code: $?"
wait "$pid_b"
echo "worker-b exit code: $?"
8. Common mistakes with job control
The most common mistake is assuming a trailing & is enough for a process to survive an SSH disconnect. Without disown or nohup, the background job receives SIGHUP when the session ends and usually dies immediately. Anyone relying on job control for long maintenance windows must build in this safeguard explicitly, before the connection is dropped, not afterward, since by then it is too late.
A second mistake is confusing %1 with the process ID. The job number %1 is only valid within the shell that started the job, while the actual PID is unique system wide. Running disown %1 in a new shell session where the job never appeared in the job table produces an error, because no job control reference exists there. A third mistake: calling bg on a job that reads from stdin without redirecting input. The process then silently stalls (SIGTTIN), which is hard to spot in logs.
9. Job control tools compared
The table below compares the most important tools around job control, ordered by the degree of detachment from the current terminal they achieve.
| Tool | Effect | Survives SSH disconnect | Typical use |
|---|---|---|---|
| & alone | Background process in job table | No | Short tasks in the same session |
| bg / fg | Status switch of a job | No | Interactive pausing and resuming |
| disown | Remove job from job table | Yes (with -h also SIGHUP-proof) | Retroactively securing running jobs |
| nohup | SIGHUP ignored from the start | Yes | Long runners planned from the outset |
| setsid | New session, no terminal | Yes, fully | True daemon processes |
For most maintenance windows, disown -h right after starting the process is entirely sufficient, since the process group stays unchanged and the job remains visible in jobs. setsid pays off once a process is meant to run permanently as a daemon with no terminal binding at all remaining, a topic covered in more depth in the article on running Bash scripts as a proper daemon.
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Maintenance windows that no longer need an SSH connection?
We build proper job control, disown safeguards and daemon operation into your Bash automation, so long migrations, backups and maintenance scripts keep running reliably even after the connection drops.
Script review
Check job control and SIGHUP safeguards in existing scripts
Daemon operation
Set up setsid, systemd units and restart strategies
Maintenance windows
Make migrations and backups resilient against dropped connections
10. Summary
Job control in Bash is a self-contained subsystem combining process groups, signals like SIGTSTP and SIGCONT, and a session-bound job table. jobs shows the state, fg and bg switch between foreground and background, and disown permanently detaches a job from the shell without terminating it. Knowing these building blocks lets you deliberately protect long maintenance work against connection drops instead of relying on luck.
For permanent processes meant to exist independently of the starting terminal, plain job control with disown is often not enough, setsid or a systemd unit is the more robust choice here. Combining nohup at start time with disown -h afterward, though, reliably covers most practical cases in backup, migration and maintenance scripts.
Job Control with fg, bg and disown — Key Takeaways
Referencing jobs
jobs -l lists every background process. %n, %+ and %- reference them unambiguously for fg, bg and kill.
Signals behind it
Ctrl-Z sends SIGTSTP, fg and bg resume with SIGCONT. The process itself is never terminated in this flow.
disown vs. nohup
disown acts retroactively on running jobs, nohup from the start. Both protect against SIGHUP at session end.
In scripts
Non-interactive scripts only get job control with set -m. Usually $! plus wait is enough for reliable process management.