Writing a Bash script for backup with tar
Greetings!

In this guide, we’ll write a universal backup script in Bash that uses the tar utility to create archives with files from local or remote hosts, perform rotation of old backups, encrypt them on the fly, and monitor disk space 😌.

Prerequisites

Before running the script, make sure your system has the following required utilities installed:

Examples in this article were run on Linux Mint 22 (Ubuntu 24.04), but the script is fully portable.

How the script works under the hood

The script logic boils down to forming an appropriate pipeline command: tar locally or tar via SSH on a remote machine → compression utility → encryption utility gpg → file to disk.

Key points:

Let’s move directly to the script.

Script

Create a file in any convenient location, for example, using vim:

BASH
vim ~/.local/bin/tar.sh
Click to expand and view more

And fill it with the following content:

BASH
#!/usr/bin/env bash

set -Eeuo pipefail

# ==== EDIT THESE ====
SOURCE_DIR="/etc"
SOURCE_FILE=()            # specific file(s)/dir(s) inside SOURCE_DIR; empty = everything
                           # e.g. SOURCE_FILE=("passwd" "My Documents")
OUTPUT_DIR="/var/backups/etc"
EXTRA_PARAMS=()           # extra tar flags, e.g. EXTRA_PARAMS=("--exclude=*.tmp")
COMPRESS_FORMAT="zstd"    # gzip|pigz|bzip2|pbzip2|xz|zstd|"" (empty = no compression)
COMPRESS_LEVEL="15"
ENCRYPT_PASSWORD=""       # empty = no encryption
FILENAME=""               # empty = auto-name from OUTPUT_DIR
LABEL_KEEP="0"            # mutually exclusive with LABEL_WEEKLY/LABEL_MONTHLY
LABEL_WEEKLY="0"
LABEL_MONTHLY="0"
REMOVE_OLD=""             # days; empty = no retention
KEEP="0"                  # 1 = when removing old backups, keep ones with 'keep' in the name
KEEP_WEEKLY=""            # keep the N most recent 'weekly'-labeled backups
KEEP_MONTHLY=""           # keep the N most recent 'monthly'-labeled backups
HASH_FILE="1"             # 1 = also write a .sha256 next to the archive
# SSH: leave SSH_HOST empty for a local backup. When set, tar runs on the
# remote host and streams the archive back (pull, not push).
SSH_HOST=""
SSH_USER="$(id -un)"
SSH_PORT="22"
SSH_KEY="$HOME/.ssh/id_ed25519"
SSH_EXTRA_PARAMS=()       # e.g. SSH_EXTRA_PARAMS=("-o" "ProxyJump=bastion")
# =====================

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd -P)"
SCRIPT_PID="$$"

if [[ -t 2 && -z "${NO_COLOR:-}" && "${TERM:-}" != "dumb" ]]; then
    LOG_GREEN=$'\033[32m'; LOG_RED=$'\033[31m'; LOG_RESET=$'\033[0m'
else
    LOG_GREEN=""; LOG_RED=""; LOG_RESET=""
fi

log() {
    local msg="$*" color=""
    [[ "$msg" == ERROR:* ]] && color="$LOG_RED"
    printf '%s[%s]%s %s%s%s\n' "$LOG_GREEN" "$(date '+%Y-%m-%d %H:%M:%S')" "$LOG_RESET" "$color" "$msg" "$LOG_RESET" >&2
}

# --- preflight (mirrors lib/checks.sh's check_utils/check_disk_space) ---
check_utils() {
    local util
    for util in "$@"; do
        command -v "$util" &> /dev/null || { log "ERROR: required utility not found: $util"; exit 1; }
    done
}

check_disk_space() {
    local dir="$1" pct
    pct=$(df "$dir" --output=pcent | tail -n 1 | tr -d ' %')
    if (( pct > 95 )); then
        log "ERROR: more than 95% of disk space used in $dir (${pct}%)"
        exit 1
    fi
    return 0
}

# --- SSH (mirrors lib/ssh.sh: accept-new against a local known_hosts, and
# ssh_remote_exec's per-token %q quoting -- not a string built for eval) ---
ssh_build_argv() {
    SSH_ARGV=("ssh" "-q" "-o" "StrictHostKeyChecking=accept-new" \
        "-o" "UserKnownHostsFile=${SCRIPT_DIR}/.known_hosts_standalone")
    SSH_ARGV+=("${SSH_EXTRA_PARAMS[@]}")
    SSH_ARGV+=("-l" "$SSH_USER" "-p" "$SSH_PORT" "-i" "$SSH_KEY" "$SSH_HOST")
}

ssh_remote_exec() {
    local -n remote_argv="$1"
    ssh_build_argv
    local quoted=() tok
    for tok in "${remote_argv[@]}"; do
        quoted+=("$(printf '%q' "$tok")")
    done
    "${SSH_ARGV[@]}" "${quoted[@]}"
}

# --- retention (mirrors lib/retention.sh's remove_old_backup) ---
remove_old_backups() {
    local output_dir="$1" remove_old="$2" keep="$3" keep_weekly="$4" keep_monthly="$5" kind="${6:-file}"
    local valid_extensions=(".tar" ".sql" ".gz" ".bz2" ".xz" ".zst" ".gpg")
    local now weekly_entries=() monthly_entries=()
    now=$(date +%s)

    local entry_path
    for entry_path in "$output_dir"/*; do
        local entry_name
        entry_name=$(basename "$entry_path")

        if [[ "$kind" == "dir" ]]; then
            [[ -d "$entry_path" && ! -L "$entry_path" ]] || continue
        else
            [[ -f "$entry_path" ]] || continue
            local matched=0 ext
            for ext in "${valid_extensions[@]}"; do
                [[ "$entry_name" == *"$ext" ]] && matched=1
            done
            (( matched )) || continue
        fi

        (( keep )) && [[ "$entry_name" == *keep* ]] && continue

        if (( keep_weekly > 0 )) && [[ "$entry_name" == *weekly* ]]; then
            weekly_entries+=("$entry_path")
            continue
        fi
        if (( keep_monthly > 0 )) && [[ "$entry_name" == *monthly* ]]; then
            monthly_entries+=("$entry_path")
            continue
        fi

        local entry_last_mod
        entry_last_mod=$(stat -c %Y "$entry_path")
        if (( now - entry_last_mod > remove_old * 86400 )); then
            log "Deleting old backup: $entry_path"
            rm -rf "$entry_path" "${entry_path}.sha256"
        fi
    done

    clean_old_generations() {
        local n="$1" label="$2"
        shift 2
        local entries=("$@")
        (( ${#entries[@]} <= n )) && return 0
        local sorted=() e
        mapfile -t sorted < <(
            for e in "${entries[@]}"; do
                printf '%s\t%s\n' "$(stat -c %Y "$e")" "$e"
            done | sort -nr -k1,1 | cut -f2-
        )
        local old
        for old in "${sorted[@]:$n}"; do
            log "Deleting old $label backup: $old"
            rm -rf "$old" "${old}.sha256"
        done
        return 0
    }

    (( keep_weekly > 0 ))  && clean_old_generations "$keep_weekly" "weekly" "${weekly_entries[@]}"
    (( keep_monthly > 0 )) && clean_old_generations "$keep_monthly" "monthly" "${monthly_entries[@]}"
    return 0
}

[[ -n "$SSH_HOST" || -d "$SOURCE_DIR" ]] || { log "ERROR: source dir not found: $SOURCE_DIR"; exit 1; }

required_utils=("tar" "flock")
[[ -n "$COMPRESS_FORMAT" ]] && required_utils+=("$COMPRESS_FORMAT")
[[ -n "$ENCRYPT_PASSWORD" ]] && required_utils+=("gpg")
[[ -n "$SSH_HOST" ]] && required_utils+=("ssh")
check_utils "${required_utils[@]}"

labels=0
[[ "$LABEL_KEEP" == "1" ]]    && labels=$((labels + 1))
[[ "$LABEL_WEEKLY" == "1" ]]  && labels=$((labels + 1))
[[ "$LABEL_MONTHLY" == "1" ]] && labels=$((labels + 1))
(( labels > 1 )) && { log "ERROR: only one label allowed"; exit 1; }

mkdir -p "$OUTPUT_DIR"

[[ -z "$FILENAME" ]] && FILENAME="backup_tar_$(basename "$(realpath -m "$OUTPUT_DIR")")"

LOCK_FILE="${SCRIPT_DIR}/.${FILENAME}.lock"
exec {lock_fd}>> "$LOCK_FILE"
flock -n "$lock_fd" || { log "ERROR: another backup for this item is already running"; exit 1; }
echo "$SCRIPT_PID" > "$LOCK_FILE"

release_lock() {
    if [[ -f "$LOCK_FILE" ]] && [[ "$(< "$LOCK_FILE")" == "$SCRIPT_PID" ]]; then
        rm -f "$LOCK_FILE"
    fi
}
trap release_lock EXIT

[[ "$LABEL_KEEP" == "1" ]]    && FILENAME="${FILENAME}_keep"
[[ "$LABEL_WEEKLY" == "1" ]]  && FILENAME="${FILENAME}_weekly"
[[ "$LABEL_MONTHLY" == "1" ]] && FILENAME="${FILENAME}_monthly"
FILENAME="${FILENAME}_$(date '+%F_%H-%M-%S').tar"

if [[ -n "$REMOVE_OLD" ]]; then
    remove_old_backups "$OUTPUT_DIR" "$REMOVE_OLD" "$KEEP" "${KEEP_WEEKLY:-0}" "${KEEP_MONTHLY:-0}" file
fi

check_disk_space "$OUTPUT_DIR"

case "$COMPRESS_FORMAT" in
    gzip|pigz)    FILENAME="${FILENAME}.gz"  ;;
    bzip2|pbzip2) FILENAME="${FILENAME}.bz2" ;;
    xz)           FILENAME="${FILENAME}.xz"  ;;
    zstd)         FILENAME="${FILENAME}.zst" ;;
    "")           ;;
    *) log "ERROR: unsupported COMPRESS_FORMAT: $COMPRESS_FORMAT"; exit 1 ;;
esac
[[ -n "$ENCRYPT_PASSWORD" ]] && FILENAME="${FILENAME}.gpg"

RESULT_FILE="${OUTPUT_DIR}/${FILENAME}"

producer=("tar" "--create")
producer+=("${EXTRA_PARAMS[@]}")
producer+=("--file=-" "--directory=${SOURCE_DIR}")
if [[ ${#SOURCE_FILE[@]} -eq 0 ]]; then
    producer+=("./")
else
    producer+=("${SOURCE_FILE[@]}")
fi

run_producer() {
    if [[ -n "$SSH_HOST" ]]; then
        ssh_remote_exec producer
    else
        "${producer[@]}"
    fi
}

run_compress() {
    case "$COMPRESS_FORMAT" in
        gzip|pigz)    "$COMPRESS_FORMAT" "-$COMPRESS_LEVEL" ;;
        bzip2|pbzip2) "$COMPRESS_FORMAT" "-$COMPRESS_LEVEL" ;;
        xz)           xz "-$COMPRESS_LEVEL" --stdout ;;
        zstd)         zstd "-$COMPRESS_LEVEL" -c ;;
        *)            cat ;;
    esac
}

run_encrypt() {
    if [[ -z "$ENCRYPT_PASSWORD" ]]; then
        cat
        return
    fi
    local tmpdir passfile
    tmpdir="$(mktemp -d /dev/shm/.encrypt.XXXXXX)"
    passfile="$tmpdir/passfile"
    trap 'rm -rf "$tmpdir"' RETURN
    printf "%s" "$ENCRYPT_PASSWORD" > "$passfile"
    chmod 600 "$passfile"
    gpg --quiet --batch --yes --symmetric --cipher-algo AES256 --homedir "$tmpdir" --passphrase-file "$passfile"
}

log "Running: ${producer[*]}$([[ -n "$SSH_HOST" ]] && echo " (on $SSH_HOST)") > $RESULT_FILE"

if run_producer | run_compress | run_encrypt > "$RESULT_FILE"; then
    log "Backup created: $RESULT_FILE"
else
    rm -f "$RESULT_FILE"
    log "ERROR: backup failed"
    exit 1
fi

if [[ "$HASH_FILE" == "1" ]]; then
    sha256sum "$RESULT_FILE" > "${RESULT_FILE}.sha256"
    log "Hash: ${RESULT_FILE}.sha256"
fi
Click to expand and view more

You only need to edit the variables between the lines # ==== EDIT THESE ==== and # =====================. And even then, not all of them.

I won’t describe every block of the script here - if needed, ask language models for clarification. They’re pretty good at this these days 😉.

Usage Examples

Below I’ll show several scenarios for using our script. All examples are working and safe - they use test directories in /tmp/, not real data.

Preparing the test environment

First, let’s create a test environment with data:

BASH
# Create directories
mkdir -vp /tmp/backup-demo/{data,config}

# Generate test files of different sizes
dd if=/dev/zero of=/tmp/backup-demo/data/file1.bin bs=1M count=10
dd if=/dev/zero of=/tmp/backup-demo/data/file2.bin bs=1M count=5
echo "app_version=1.0" > /tmp/backup-demo/config/app.conf
echo "db_host=localhost" > /tmp/backup-demo/config/db.conf
echo "secret_token=abc123" > /tmp/backup-demo/config/.env

# Output directories for backups
mkdir -vp /tmp/backups/{local,remote,weekly,monthly,encrypted}

# Check that everything is ready
ls -lh /tmp/backup-demo/
du -sh /tmp/backup-demo/*
Click to expand and view more

Example 1: Simple backup with zstd compression

A simple case - copy a directory with data, compress the archive with the modern zstd algorithm (good balance of speed and compression ratio). Get an archive with a hash for integrity verification. This is suitable for daily backups without special requirements.

In our script, adjust the following variables:

BASH
#!/usr/bin/env bash

SOURCE_DIR="/tmp/backup-demo"
OUTPUT_DIR="/tmp/backups/local"
COMPRESS_FORMAT="zstd"
COMPRESS_LEVEL="15"
HASH_FILE="1"
SSH_HOST=""
Click to expand and view more

Add execute permission to the script and run it:

BASH
chmod +x ~/.local/bin/tar.sh

~/.local/bin/tar.sh
Click to expand and view more

After execution, we’ll see output like this in the terminal:

Check the integrity of the archive:

BASH
bash -c 'cd /tmp/backups/local/; sha256sum -c *.sha256'
Click to expand and view more

The hashes should match:

View archive contents using the same tar with the -t flag:

BASH
tar -tvf /tmp/backups/local/backup_tar_local_2026-08-20_07-38-39.tar.zst
Click to expand and view more

Example 2: Backup of specific files with GPG encryption

We don’t always need the entire directory. Our script can copy individual files, for example, configs, and add AES256 encryption using the gpg utility.

Edit the variables:

BASH
#!/usr/bin/env bash

SOURCE_DIR="/tmp/backup-demo"
SOURCE_FILE=("config")           # only config folder, without data/
OUTPUT_DIR="/tmp/backups/encrypted"
COMPRESS_FORMAT="zstd"
COMPRESS_LEVEL="15"
ENCRYPT_PASSWORD="i-like-r4ven-me"  # in production code, better read from environment variable
HASH_FILE="1"
SSH_HOST=""
Click to expand and view more

Start:

BASH
~/.local/bin/tar.sh
Click to expand and view more

Create the /tmp/restore directory and try to extract and decrypt (password will be prompted) our archive into it:

BASH
mkdir -vp /tmp/restore/

gpg --quiet --decrypt /tmp/backups/encrypted/*.tar.zst.gpg | zstd -d | tar -xvC /tmp/restore/
Click to expand and view more

Example 3: Backup with rotation and retention policy

Backups have a tendency to accumulate, so they need to be rotated properly. Set up the following storage policy: archives older than 7 days - delete, if the name contains “keep” - keep always, the rest by date. Run the script several times to see the deletion.

Edit the script variables:

BASH
#!/usr/bin/env bash

SOURCE_DIR="/tmp/backup-demo"
OUTPUT_DIR="/tmp/backups/weekly"
COMPRESS_FORMAT="zstd"
COMPRESS_LEVEL="12"
HASH_FILE="1"
REMOVE_OLD="7"              # delete backups older than 7 days
KEEP="1"                    # if the filename contains "keep" - don't delete
KEEP_WEEKLY="3"             # keep 3 most recent weekly backups
LABEL_WEEKLY="1"            # add "_weekly" suffix
SSH_HOST=""
Click to expand and view more

Run several times to create multiple archives:

BASH
for i in {1..5}; do ~/.local/bin/tar.sh; sleep 1; echo '---'; done
Click to expand and view more

We see that when creating the 5th archive, the script sees 4 weekly archives (but should keep 3 - KEEP_WEEKLY="3") and deletes the oldest one. As a result, there will be 4 backups: 3 kept + the latest.

Now let’s disable weekly backup retention (KEEP_WEEKLY="0") and artificially reduce the modification date of one of the archives by 8 days:

BASH
sed -i 's/KEEP_WEEKLY="3"/KEEP_WEEKLY="0"/' ~/.local/bin/tar.sh

touch -d '-8 days' /tmp/backups/weekly/backup_tar_weekly_2026-08-20_08-22-37.tar.zst.gpg
Click to expand and view more

Run the script again:

BASH
~/.local/bin/tar.sh
Click to expand and view more

We see that a backup older than 7 days was deleted.

Now let’s make another file older than 7 days, but add the word keep to the filename:

BASH
touch -d '-8 days' /tmp/backups/weekly/backup_tar_weekly_2026-08-20_08-45-21.tar.zst.gpg

mv -v /tmp/backups/weekly/backup_tar_weekly_{,keep_}2026-08-20_08-45-21.tar.zst.gpg
Click to expand and view more

Try to run a backup:

BASH
~/.local/bin/tar.sh
Click to expand and view more

Nothing was deleted.

But if we rename it back without keep and run again:

BASH
mv -v /tmp/backups/weekly/backup_tar_weekly_{keep_,}2026-08-20_08-45-21.tar.zst.gpg

~/.local/bin/tar.sh
Click to expand and view more

We’ll see the expected result:

Example 4: SSH backup from a Docker container

Sometimes you need to copy data from a remote host via SSH. To avoid cluttering and overloading our machines, we use a Docker container with an sshd daemon inside. The script will connect to the container and copy its files.

First, prepare the Docker environment using cat and here doc:

BASH
# Create a simple Dockerfile with sshd
mkdir -vp /tmp/ssh-demo

cat > /tmp/ssh-demo/Dockerfile << 'EOF'
FROM ubuntu:22.04

RUN apt-get update && apt-get install -y openssh-server tar zstd && \
    mkdir -p /run/sshd && \
    mkdir -p /root/.ssh && \
    ssh-keygen -A

# Allow root login (demo only!)
RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config && \
    mkdir -p /root/.ssh/authorized_keys && \
    chmod 700 /root/.ssh/authorized_keys

CMD ["/usr/sbin/sshd", "-D"]
EOF

# Build the image
docker build -t backup-demo-sshd /tmp/ssh-demo/

# Run the container
docker run -d --name backup-demo-ssh -p 2222:22 -v /tmp/backup-demo:/data:ro backup-demo-sshd
Click to expand and view more

Import our public key for the root user in the container:

BASH
docker exec backup-demo-ssh sh -c "echo $(cat ~/.ssh/id_ed25519.pub) > /root/.ssh/authorized_keys"

docker exec backup-demo-ssh chmod 600 /root/.ssh/authorized_keys
Click to expand and view more

Now edit our backup script:

BASH
#!/usr/bin/env bash

SOURCE_DIR="/data"               # path on remote machine
SOURCE_FILE=()
OUTPUT_DIR="/tmp/backups/remote"
COMPRESS_FORMAT="zstd"
COMPRESS_LEVEL="15"
ENCRYPT_PASSWORD=""
HASH_FILE="1"
SSH_HOST="127.0.0.1"             # or container IP
SSH_USER="root"
SSH_PORT="2222"                  # port we opened
SSH_KEY="$HOME/.ssh/id_ed25519"  # or any other key
SSH_EXTRA_PARAMS=("-C")
Click to expand and view more

Run the backup:

BASH
~/.local/bin/tar.sh
Click to expand and view more

Check the result:

BASH
ls -lh /tmp/backups/remote/

# Extract and verify
mkdir -vp /tmp/restore-ssh

tar -xf /tmp/backups/remote/backup_tar_remote_2026-08-20_09-34-15.tar.zst -C /tmp/restore-ssh/
Click to expand and view more

Don’t forget to clean up Docker:

BASH
docker stop backup-demo-ssh

docker rm backup-demo-ssh

docker rmi backup-demo-sshd
Click to expand and view more

Example 5: Monthly and weekly labels with different rotation

In production systems, you often need a strategy like “keep all daily backups for a week, all weekly backups for 3 months, all monthly backups for a year”. Set up the script with labels and different retention policies.

BASH
#!/usr/bin/env bash

SOURCE_DIR="/tmp/backup-demo"
OUTPUT_DIR="/tmp/backups/monthly"
COMPRESS_FORMAT="zstd"
COMPRESS_LEVEL="12"
HASH_FILE="1"
REMOVE_OLD="1"              # delete older than 1 day (for demo)
KEEP_MONTHLY="3"            # keep 3 most recent monthly backups
LABEL_MONTHLY="1"           # add "_monthly" suffix
SSH_HOST=""
Click to expand and view more

Run the backup:

BASH
~/.local/bin/tar.sh
Click to expand and view more

Now simulate several monthly backups:

BASH
for i in {1..5}; do ~/.local/bin/tar.sh; echo '---'; sleep 1; done

ls -l /tmp/backups/monthly/*.tar.zst
Click to expand and view more

The directory will keep only 3 monthly backups, old ones will be deleted.

Example 6: Backup with file exclusion

When copying large directories, you often need to exclude temporary files, logs, caches, etc. Add tar flags for filtering:

BASH
#!/usr/bin/env bash

SOURCE_DIR="/tmp/backup-demo"
OUTPUT_DIR="/tmp/backups/local"
COMPRESS_FORMAT="zstd"
COMPRESS_LEVEL="15"
HASH_FILE="1"
EXTRA_PARAMS=(
    "--exclude=*.tmp"
    "--exclude=.cache"
    "--exclude=.env"
    "--exclude=__pycache__"
)
SSH_HOST=""
Click to expand and view more

Run backup:

BASH
~/.local/bin/tar.sh
Click to expand and view more

Check that the existing .env file didn’t get into the archive:

BASH
tar -tvf /tmp/backups/local/backup_tar_local_2026-08-20_17-54-30.tar.zst
Click to expand and view more

Example 7: Backup without compression for maximum speed

If maximum speed is needed, you can disable compression and get a pure TAR archive:

BASH
#!/usr/bin/env bash

SOURCE_DIR="/tmp/backup-demo"
OUTPUT_DIR="/tmp/backups/local"
COMPRESS_FORMAT=""          # empty - no compression
COMPRESS_LEVEL=""
HASH_FILE="1"
EXTRA_PARAMS=("--verbose")  # see the process
SSH_HOST=""
Click to expand and view more

Run the script:

BASH
~/.local/bin/tar.sh
Click to expand and view more

The final archive file will be almost the same size as the source data, but will be created significantly faster. Can be useful if the storage already compresses data at the filesystem level.

Cleanup of test environment

After experiments, delete the test data:

BASH
rm -vrf /tmp/backup-demo /tmp/backups /tmp/ssh-demo /tmp/restore*
Click to expand and view more

Afterword

The script turned out quite universal. It covers most tasks for simple file/folder backups.

It also has some protection mechanisms, for example, checking available disk space (must be less than 95% used), otherwise an error:

TXT
~/.local/bin/tar.sh
[2026-08-20 18:50:54] ERROR: more than 95% of disk space used in /tmp/backups/local (97%)
Click to expand and view more

Or preventing race conditions: if one instance of the script hasn’t completed yet, running another with the same parameters won’t proceed:

TXT
~/.local/bin/tar.sh
[2026-08-20 18:53:18] ERROR: another backup for this item is already running
Click to expand and view more

In the next article, we’ll look at a similar script, but using rsync. Subscribe to the telegram channel so you don’t miss it.

If you’re looking for a comprehensive solution for your self-hosted infrastructure, I recommend considering such a great tool as Arkeep - a server with a GUI panel, based on the wonderful restic and rclone.

Thanks for reading! Take care of your backups.

Useful Materials

Copyright Notice

Author: Ivan Cherniy

Link: https://r4ven.me/en/automation/pishem-bash-skript-dlya-rezervnogo-kopirovaniya-s-pomoshchyu-tar/

License: CC BY-NC-SA 4.0

Blog materials may be used with attribution to the author and source, for non-commercial purposes, and under the same license.

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut