Module 5: Remote Machines, Networking, and Scripting

5. Moving files and sessions that survive: scp, rsync, and tmux

Description

By the end of this lesson you will be able to move files to and from a remote machine with the right tool for each case — a one-off copy with scp, a real sync with rsync that only moves what changed — and you will be able to launch a process that runs for hours on a remote server and leave it running even if you close your laptop, turn off Wi-Fi, or your internet connection drops.

This is not a matter of convenience. It is, probably, the first real operational scare you are going to have working with a remote machine: you connect over SSH (you already know how, from the previous lesson), you launch a training run, a database migration, or a multi-gigabyte rsync, you close your laptop to go to lunch, and when you come back the process is dead halfway through — with no error message explaining it, because from the process's point of view, its power simply got cut. Almost anyone who works with remote servers discovers this once, usually losing hours of work, and never forgets it again.

Connection to the module: you already know how to connect over SSH with an alias from ~/.ssh/config and key authentication. This lesson assumes that connection is a solved problem and focuses on two questions that come right after it: how do you move files between your machine and the remote one without reinventing cat by hand?, and how do you make a remote process survive you disconnecting? The next lesson takes the natural next step: turning the commands you already master into an executable file — your first shell script.


Copy once: scp

Think of scp as a photocopier: you feed in a sheet, an identical copy comes out on the other side, no questions asked. If you run the same command again five minutes later, it copies everything from scratch again — it does not care that the destination file already exists and is identical, or that only one line changed in a two-gigabyte file. scp (secure copy) uses the same encrypted channel you already know from ssh, and its syntax is nearly literal: where in cp you would write source and destination on your own machine, in scp either one — or both — can carry the user@host: prefix.

A technical note worth knowing: since OpenSSH 9.0 (2022), scp uses the SFTP protocol by default to transfer files, not the original scp protocol from the eighties — a change transparent in the syntax you use, but one that fixes several security weaknesses in the old protocol. If you ever see the -O flag in an old script, it is there to force the legacy protocol against a server that does not support SFTP yet; in this module's normal work you are never going to need it.

Worked example

Copy a local file to your server, using the alias you set up in ~/.ssh/config in the previous lesson:

scp report.csv myserver:/home/alex/reports/

What to expect:

report.csv                                    100%  350KB   4.1MB/s   00:00

To bring something back, the remote source and the local destination simply swap places:

scp myserver:/home/alex/reports/summary.csv ./

And for a whole folder, add -r (recursive) — just like you would with cp:

scp -r myserver:/home/alex/reports ./local-reports

What to expect (one progress line per file inside the folder):

sales.csv                                     100%   82KB   3.9MB/s   00:00
inventory.csv                                 100%  128KB   4.0MB/s   00:00

scp is perfect for this: "I need this specific file, right now, just once." For an entire project you are going to update again and again — a code directory, a static site, a backups folder that grows every day — scp becomes slow and clumsy, because it always copies everything again. That is where rsync comes in.


Syncing for real: rsync

If scp is a photocopier, rsync is an accountant reconciling two ledgers: before moving a single byte, it compares what exists at the destination against what exists at the source — name by name, and within each file, block by block — and only transmits the difference. A two-gigabyte file where four lines changed syncs in seconds, not because rsync is "faster" in the abstract, but because it literally moves less data. That is the reason rsync (remote sync) is the real working tool for anything that is not a one-time copy: code, backups, data directories, entire sites.

The flags you are going to use in nearly every real invocation are -avz: -a (archive) turns on recursion and preserves permissions, owner, group, timestamps, and symlinks in one shot — it is "copy it exactly as it is, not some generic version"; -v (verbose) shows you which files are being transferred; -z (compress) compresses the data in transit, which helps a lot on slow connections and costs almost nothing on a fast network.

Worked example

Before syncing anything for real, always run --dry-run (or its short form -n) first — it shows you exactly what rsync would do without moving a single byte:

rsync -avz --dry-run project/ myserver:/var/www/project/

What to expect (a full simulation, no real transfer yet):

sending incremental file list
./
src/app.py
src/config.py
static/style.css

sent 1,204 bytes  received 84 bytes  2,576.00 bytes/sec
total size is 48,213  speedup is 37.44 (DRY RUN)

The (DRY RUN) line at the end is your confirmation that nothing moved. If the file list is what you expected, remove --dry-run and run the exact same command to actually make it happen:

rsync -avz project/ myserver:/var/www/project/

Now, the part that changes the entire result without changing a single flag: the trailing slash on the source path. Compare these two commands, identical except for one slash:

rsync -avz project  myserver:/var/www/
rsync -avz project/ myserver:/var/www/

What to expect in each case, if /var/www/ was empty before running the command:

# No trailing slash on the source ("project"):
# rsync copies the FOLDER, name included
/var/www/project/src/app.py
/var/www/project/static/style.css

# Trailing slash on the source ("project/"):
# rsync copies the CONTENTS of the folder, without the folder's name
/var/www/src/app.py
/var/www/static/style.css

That is the rule, literally: a trailing slash on the source tells rsync "copy what is inside this folder"; without it, you are telling it "copy this folder whole, exactly as it is, name and all." The trailing slash on the destination does not change anything — only the one on the source matters.

Two more flags you are going to need almost right away. --exclude leaves out of the sync whatever pattern you point it at — typically heavy, regenerable folders that should never travel anywhere:

rsync -avz --exclude='.git' --exclude='node_modules' project/ myserver:/var/www/project/

And --delete makes the destination end up as an exact mirror of the source: if a file existed at the destination but is no longer at the source, rsync deletes it. That is exactly what you want for a backup that must reflect the current state, and exactly what you do not want if you got the direction of the command backwards. --delete combined with source and destination accidentally swapped can wipe out, in seconds, a folder that took you months to build, without asking anything. That is why the correct sequence is always: --dry-run first with --delete included, calmly read which files show up under deleting, and only then run the real command:

rsync -avz --delete --dry-run backups/ myserver:/data/current/

What to expect (the files that would be deleted show up marked, with nothing actually deleted yet):

deleting old-report-2024.csv
deleting cache/tmp_4471.dat
sent 412 bytes  received 96 bytes  1,016.00 bytes/sec
total size is 91,204  speedup is 179.53 (DRY RUN)

If that list makes sense — they are files that really should not be there anymore — remove --dry-run and run the same command for real.


The break nobody sees coming: why your remote process dies when you close your laptop

Picture your SSH session as a phone call: for as long as the call lasts, everything you say reaches the other end. The process you launched on the remote server — a long script, a build, that multi-gigabyte rsync — was born inside that call, tied to it like an extension of the same phone. When you hang up — you close the terminal, you close your laptop, your Wi-Fi drops — the remote operating system sends that process a signal called SIGHUP ("hang up," literally "the call got hung up"), and by default almost any program that receives that signal terminates immediately. It is not a bug in the process or the network: it is the system's expected behavior. The process was never designed to survive without the call that was holding it up.

There are two different ways to solve this, and choosing between them is the real skill in this section — not memorizing the commands, but knowing which one fits which situation.

tmux creates a room that exists independently of the call. Instead of your process living inside your SSH session, it lives inside a tmux session running on the remote server, and your SSH session just connects to that room like someone walking in to look at a screen — you can leave the room (disconnect, or in tmux's jargon, detach) and the room stays there, with everything running inside it, whether or not your SSH connection exists. You come back whenever you want, reconnect to the same room (attach), and see the screen exactly as you left it, with the process still running or already finished.

nohup (plus &) tells the process "ignore the hang-up signal." It is lighter than tmux because it does not create any room or persistent screen: it just tells the process that, if SIGHUP reaches it, ignore it and keep running. The cost is that you have no way to "look back in" on that process live like it was a terminal — you can only check its output file with something like tail -f, or confirm with ps that it is still alive.

Worked example

Create a new tmux session with a name — always give it a name, do not let it use the default number, because a name makes it recognizable weeks later:

tmux new -s deploy

Inside that session — which the system treats as a normal terminal — you launch the long process exactly as you always would:

rsync -avz big-dataset/ myserver:/data/archive/

While it runs, disconnect without killing anything: press Ctrl-b and release, then press d (tmux's prefix is Ctrl-b; almost every tmux command starts by releasing those two keys and then pressing a third one). You are back at your normal shell:

[detached (from session deploy)]

Close your laptop if you want — the session stays alive on the server. When you connect over SSH again, first confirm what sessions exist:

tmux ls

What to expect:

deploy: 1 windows (created Mon Jul 20 14:02:31 2026)

And reconnect to that specific session by name:

tmux attach -t deploy

You see exactly the same screen again, with the rsync either already finished or still running. Once the work is done and you no longer need the session, close it explicitly (just exiting the shell inside it is not enough; that only closes the window, not necessarily the session if it has more than one):

tmux kill-session -t deploy

If your server does not have tmux installed and you do not want to install it for a one-off task, nohup together with & (which you already know from the processes module) solves the same problem with no persistent session at all:

nohup rsync -avz big-dataset/ myserver:/data/archive/ &

What to expect (the number in brackets is the background process's PID):

[1] 48213
nohup: ignoring input and appending output to 'nohup.out'

That nohup.out file gets created in the directory you were standing in, with all the output — standard and error — mixed together inside. To avoid depending on that generic name or having to hunt for it later, redirect the output yourself to a file with its own name; in that case nohup does not even need to create anything, because there is no longer any output without a destination:

nohup rsync -avz big-dataset/ myserver:/data/archive/ > sync.log 2>&1 &

What to expect: no warning line at all — the full output, standard and error (thanks to 2>&1), lands directly in sync.log, not in nohup.out.

You can close the SSH session with peace of mind; the process keeps running on the server, indifferent to the hang-up signal. To check its progress later, with nothing to "reopen":

tail -f sync.log

screen exists as an older alternative to tmux — it solves the same problem, sessions that survive disconnection, with different syntax (screen -S name to create, Ctrl-a d to detach, screen -r name to reconnect) and comes preinstalled on more older systems. If you already know tmux, there is no practical reason to also learn screen; if you connect to a server where tmux does not exist and you cannot install it, screen is usually there as a backup.

The criterion for choosing, in one sentence: if you need to see the process live, interact with it, or you are going to organize several things at once in separate screens, use tmux; if it is a one-time command, you do not need to watch it run, and you just want to check the result at the end in a log file, nohup ... & is faster and does not depend on tmux being installed.


Common mistakes

"I closed my laptop for five minutes, and the two-hour rsync ended up dead halfway through, with no error at all." What happens: the process ran directly inside your SSH session, with no tmux or nohup involved, so when the connection dropped, the system sent it SIGHUP and the process died with it. Why it happens: by default, a process is tied to the lifecycle of the session that launched it; surviving a disconnection is the exception, not the rule, and you have to explicitly ask for it with tmux or nohup. How to spot it: check with ps aux | grep rsync on the server as soon as you reconnect — if the process is no longer there and the destination file ended up incomplete, that is the signature. How to fix it: launch any process that runs longer than a couple of minutes inside a tmux session, or prefix it with nohup — never directly in an unprotected SSH session.

"I ran rsync -avz project myserver:/var/www/ and now I have a duplicated /var/www/project/project/." What happens: the source had no trailing slash, so rsync copied the whole folder — name and all — into a destination that already had a folder with that same name from an earlier run. Why it happens: the trailing slash on the source decides whether rsync treats the path as "the contents" or as "the whole folder," and it is easy to miss that difference between two nearly identical commands, especially if you copied one from a different example. How to spot it: before running any rsync toward a destination that already has content, run it first with --dry-run and read the list of paths that would get created — if you see your folder's name repeated in the resulting path, there is the sign. How to fix it: decide intentionally whether you want the contents (trailing slash on the source) or the whole folder (no slash), and if it already ended up duplicated, go into the destination and move the contents up one level with mv before continuing to sync.

"I used --delete to 'clean up' the destination and now files I actually needed are missing." What happens: --delete removed files at the destination that were no longer at the source at that moment — an incomplete local folder, one --exclude too many, or simply source and destination swapped by a typo — and those "leftover" files disappeared without any warning. Why it happens: --delete does not distinguish "a file that should no longer be there" from "a file missing because of your mistake"; for rsync, anything not at the source at that instant is a candidate for deletion at the destination, no exceptions. How to spot it: if you ran --delete without --dry-run first, the only way to notice is after the fact, checking what is missing — which is exactly what you want to avoid. How to fix it going forward: --delete is never run for the first time without --dry-run in front of it, no exceptions; you read the full list of deleting lines before removing the flag, and if the destination is something you cannot afford to lose, also keep a backup copy separate from the rsync itself.


Exercises

1. Read the trailing slash

Your local folder site/ contains index.html and styles.css. The remote server has an empty folder at /var/www/. You run:

rsync -avz site myserver:/var/www/

What exact path will index.html have on the server when it finishes? And if you had instead run rsync -avz site/ myserver:/var/www/, what would the path have been?

See solution

With no trailing slash on the source (site), rsync copies the whole folder into the destination: the final path is /var/www/site/index.html.

With a trailing slash on the source (site/), rsync copies only the contents: the final path is /var/www/index.html, with no site folder in between.

Why it works: the trailing slash on the source path tells rsync to treat that path as "what is inside," not as "the folder itself" — it is the only difference between the two commands, and it completely changes the resulting structure.

2. Decide whether the --dry-run is safe to run for real

You ran this and got the following output:

rsync -avz --delete --dry-run current-build/ myserver:/var/www/production/
deleting old-vendor-bundle.js
deleting debug.log
sending incremental file list
src/main.js
src/styles.css

sent 890 bytes  received 210 bytes  2,200.00 bytes/sec
total size is 210,442  speedup is 191.31 (DRY RUN)

Would you run this command with --dry-run removed? Justify it with what you see in the output.

See solution

Yes, in principle it is safe: the two files marked with deleting (old-vendor-bundle.js and debug.log) are exactly the kind of file you would expect to remove in a production sync — an old bundle already replaced and a debug log that should not be on the server. If instead the deleting list included something like config/database.yml or an entire folder of user data, that would be a clear signal to stop and check whether the source and destination are swapped, or whether something is missing from the local directory before syncing.

Why it works: the exact purpose of --dry-run combined with --delete is to give you this list to review before anything irreversible happens — the decision to actually run it depends on reading every deleting line, not on assuming it is fine.

3. Choose the right tool

You have three situations. For each one, decide whether you would use tmux or nohup ... &, and in one sentence, justify why:

a) A fifteen-minute backup script you run once a month, with no need to watch it while it runs. b) A training process that takes several hours, where you want to be able to check progress live now and then, and maybe launch a second command in parallel while the first one keeps running. c) A test server where neither tmux nor screen is installed, and you do not have permissions to install anything.

See solution

a) nohup ... &: it is a one-time command, you do not need to watch it run live, and it is not worth creating a persistent session for something you only check at the end in a log.

b) tmux: you need to watch the process live and possibly organize more than one thing at once in separate screens — exactly the case where a persistent session with an interface pays off.

c) nohup ... &: with no permissions to install anything, you have no way to get tmux or screen; nohup ships with practically any Unix system by default, no extra installation needed.

Why it works: the criterion is not "which one is better" in the abstract, but whether you need to go back and watch the process running (tmux) or you just need it to survive without you watching it (nohup), and what is available on the server you are standing on.

4. Rebuild the tmux sequence

Write, in order, the commands to: create a tmux session named import, leave it without killing it, confirm it still exists, go back into it, and finally destroy it completely.

See solution
tmux new -s import           # creates the session
# inside the session: Ctrl-b, release, then d   -> detaches without killing anything
tmux ls                      # confirms "import" is still in the list
tmux attach -t import        # goes back into the same session
tmux kill-session -t import  # destroys it completely

Why it works: detaching (Ctrl-b d) and killing (kill-session) are different operations — detaching just takes you out of view, the session stays alive on the server; killing it actually ends it, along with whatever process was running inside.


Summary and next step

Before moving on you should be able to:

  • choose between scp (one-off copy) and rsync (real sync that only moves the difference) based on whether you are going to repeat the operation or not;
  • explain what the trailing slash on an rsync source changes, and run --dry-run before any --delete without anyone having to remind you;
  • explain why a remote process dies when your SSH session drops (SIGHUP), and solve it with tmux (create, detach, reconnect, list, kill sessions) or with nohup ... & depending on whether you need to watch the process live or not.

Today your "script" was still a sequence of commands you typed one by one, even if they now ran inside a session that survives your disconnection. The next step is to stop typing that sequence every time: turn it into a file that runs on its own, with its own arguments — your first shell script.


Resources