Before you deploy a single line of code at a client, you have to survive their machine. This is the dense survival guide: files, permissions, pipes, SSH, and the Git habits that keep you from being the person who lost the repo on day one.
Thursday, 4:40 PM. Tom calls from the borough field office. He needs live 311 complaint numbers for a Monday meeting and the analyst laptop "has everything on it." You remote in and find: an unfamiliar Linux machine, no Git installed, a Downloads folder full of final_v2_REAL(3).csv, a script called run.sh that nobody can execute, and no record of how any of it was produced.
Maria's words from discovery echo: "We need this to survive after you leave." Nothing here would survive the weekend.
Your first client environment is the machine itself. Before CityOps exists as a project, it has to exist as a place — files that are findable, permissions that are sane, history that is honest. That's what this post builds.
The terminal, in one mental model
Here is the minimal concept everything else hangs on: the terminal is a conversation with the operating system, and everything you talk about is a path. Files, folders, devices, even running processes — Unix presents them all as locations in one tree that starts at /. Read a path like a street address, from large to small: /home/fde/cityops/scripts/pull_311.sh means "start at the root of the machine, go into home, then fde, then cityops, then scripts — the file you want is pull_311.sh." A command is just a small program that reads from somewhere, does one thing, and writes somewhere.
Tom's laptop is what happens without that shared language. Let's build the habit properly, step by step.
Where am I, and what's here
$ pwd
/home/fde
$ ls -la
total 28
drwxr-xr-x 5 fde fde 4096 Oct 2 16:40 .
drwxr-xr-x 3 root root 4096 Oct 2 16:40 ..
-rw------- 1 fde fde 512 Oct 2 16:40 .bash_history
drwxr-xr-x 2 fde fde 4096 Oct 2 16:40 cityops
drwxr-xr-x 2 fde fde 4096 Oct 2 16:40 Downloads
$ cd cityops && pwd
/home/fde/cityops
pwd tells you where you stand. ls -la shows everything including hidden files (the dotfiles — where your real configuration lives, more on that later). cd moves you. That is 90 percent of navigation. Absolute paths start at /; relative paths start where you are. When a client's instructions say "run the script," your first question is always: from which directory?
Make a place for the work
$ mkdir -p cityops/{app,data/raw,scripts,notes}
$ touch cityops/notes/discovery-2026-10-02.md
$ ls cityops
app data notes scripts
mkdir -p creates nested folders in one move; the {a,b,c} brace expansion saves you three commands. touch creates an empty file. Small discipline, large payoff: when Dev asks "where did that CSV come from," you point at data/raw/ instead of your Downloads folder.
Permissions: who is allowed to do what
Remember run.sh that nobody could execute? That is a permissions problem — the single most common "it works on my machine" failure on client systems. Look again at that ls -la output:
$ ls -l scripts/
-rw-r--r-- 1 fde fde 214 Oct 2 16:41 pull_311.sh
$ ./scripts/pull_311.sh
bash: ./scripts/pull_311.sh: Permission denied
The first column, -rw-r--r--, is the permission mask: one slot for the file type (- for file, d for directory), then three slots each for owner, group, and everyone else. Each slot is read (r), write (w), execute (x). The script is readable but not executable — the OS refuses to run it. The fix:
$ chmod 755 scripts/pull_311.sh
$ ls -l scripts/
-rwxr-xr-x 1 fde fde 214 Oct 2 16:41 pull_311.sh
755 is three digits, one per audience — owner, group, everyone else. Each digit is a sum: 4 for read, 2 for write, 1 for execute. So 7 = 4+2+1 (read, write, execute), 5 = 4+1 (read and execute, no write). Owner gets 7, everyone else gets 5: the team can run the script but only you can change it. Files you only read get 644 (owner can edit, nobody can execute). Directories need the execute bit just to be entered at all. That is the whole system, and it explains a remarkable number of production mysteries.
Break it: the chmod 777 incident
The lazy fix you'll see on client machines is chmod -R 777 — everyone can do everything, recursively. It "works" until it matters. Here's what breaks:
$ chmod -R 777 cityops/
# It runs. Everything is readable, writable, executable by anyone.
# Two weeks later: SSH refuses your key (~/.ssh must NOT be
# world-writable), a teammate's script overwrites your data file,
# and Lisa asks why the credentials file is readable by every
# account on the box.
Production-safe rule: permissions are a security boundary, not a convenience setting. Give the minimum that works — 755 for scripts and directories, 644 for data and config, 600 for anything with a secret in it. When something "can't be read," check the mask before you reach for sudo. And never sudo a command you don't understand — on a client's machine, root can do damage no Git history can undo.
Pipes: small tools, chained
The terminal's real power isn't any single command — it's the pipe, |, which feeds one program's output into the next. This is how you interrogate data you have never seen before, on a machine with nothing installed. Let's pull real 311 data and ask it questions:
$ curl -s 'https://data.cityofnewyork.us/resource/erm2-nwe9.json?$limit=2000' -o data/raw/requests.json
$ wc -c data/raw/requests.json
2971101 data/raw/requests.json
$ head -c 600 data/raw/requests.json
[{"unique_key":"70591261","created_date":"2026-10-01T02:05:23.000",
"agency":"NYPD","agency_name":"New York City Police Department",
"complaint_type":"Noise - Street/Sidewalk","descriptor":"Loud Music/Party",
"borough":"BRONX","status":"In Progress", ...
That's one real 311 record — a Bronx street-noise complaint, currently in progress. Two thousand records sit in requests.json. Let's interrogate them with one pipeline (read it right to left):
$ grep -o '"complaint_type":"[^"]*"' data/raw/requests.json | sort | uniq -c | sort -rn | head -5
392 "complaint_type":"Noise - Residential"
356 "complaint_type":"Illegal Parking"
300 "complaint_type":"Noise - Street/Sidewalk"
92 "complaint_type":"Blocked Driveway"
87 "complaint_type":"Noise - Commercial"
In one line you just profiled a dataset Dev was skeptical about — no Python, no notebook, no dependencies. (Your counts will differ from these — the feed updates daily — but the shape of the answer is the same.) That is why FDEs live in the terminal: it works on practically every machine you will ever be handed.
A few more verbs worth having in your hands:
$ grep -i "noise" data/raw/requests.json | wc -l # how many records mention noise? (case-insensitive)
919
$ grep -o '"borough":"[^"]*"' data/raw/requests.json | sort | uniq -c | sort -rn # complaints by borough
$ diff notes/discovery-2026-10-02.md notes/discovery-2026-10-02.md.bak # what changed between two files?
$ find . -name "*.csv" -newer notes/discovery-2026-10-02.md # which CSVs arrived since the discovery note?
Myth 1: "The terminal is a power-user toy — real engineers use dashboards."
Dashboards are built on top of machines like this one. When a client's pipeline breaks at 2 AM, there is no dashboard — there is an SSH session, a log file, and these commands. The terminal isn't retro. It's the tool that still works when everything fancier has failed.
Field diagnostics: the table to keep
On a client machine, you will hit the same five failures repeatedly. Learn the symptom, not just the command:
| Symptom | What it probably means | First command |
|---|---|---|
Permission denied | Missing execute bit, or wrong owner | ls -l on the file |
command not found | Tool not installed, or not on PATH | which python3, then install |
No such file or directory | Wrong working directory, or typo | pwd, then ls |
| Script hangs with no output | Waiting for typed input, or a network call with no timeout | Ctrl-C, then re-run with explicit input |
curl returns nothing / error 6 | DNS or network blocked (proxy!) | curl -v to see where it dies |
SSH: your tunnel into their world
You will rarely sit at the client's machine. More often you get an IP address, a username, and a message like "the firewall is open, good luck." SSH (Secure Shell) is the encrypted tunnel you work through — and the first thing to set up properly, because Lisa will ask about it.
$ ssh-keygen -t ed25519 -C "fde-cityops-laptop"
# Generates ~/.ssh/id_ed25519 (private — never share) and
# ~/.ssh/id_ed25519.pub (public — safe to hand out)
$ ssh-copy-id ops@203.0.113.44
# Installs your public key on the server. Next login needs no password.
$ ssh ops@203.0.113.44
Welcome to Ubuntu 24.04.1 LTS
ops@cityops-vm:~$
Passwords get phished, shared in Slack, and forgotten. Keys don't. A config file keeps the connection details somewhere auditable instead of in your shell history:
$ cat ~/.ssh/config
Host cityops
HostName 203.0.113.44
User ops
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 60
$ ssh cityops # that's it
Break it: locking yourself out
The classic self-inflicted outage: you "tighten security" by disabling password login in the server's SSH settings file (sshd_config) before confirming your key works — from a session that then drops. Recovery means console access through the cloud provider, or asking the client's IT team to fix your change. Both are embarrassing. The production-safe order is: add the key, open a second terminal, verify the key login works in the new session, and only then remove the old method. Never change the only door while you're standing in it.
For Lisa's file: SSH keys are per-person, revocable (delete the public key from the server), and never cross the network after setup. That is the sentence she needs. Keep a list of whose keys are on which client machine — you will be asked for it during every security review.
Git: the logbook that never lies
Why did Tom's laptop have final_v2_REAL(3).csv? Because without version control, humans version files by hand — badly. Git replaces all of that with one idea: every change is recorded as a commit — a snapshot plus a message explaining why — and the full history travels with the project. When Maria asks "what changed since the demo," you don't reconstruct it from memory. You show her the log.
$ cd cityops
$ git init
Initialized empty Git repository in /home/fde/cityops/.git/
$ git config user.name "FDE"
$ git config user.name
FDE
$ git config user.email "fde@pathtofde.example"
$ echo "# CityOps" > README.md
$ git add README.md notes/discovery-2026-10-02.md
$ git status --short
A README.md
A notes/discovery-2026-10-02.md
$ git commit -m "Scaffold CityOps repo; add discovery note from Maria interview"
[main 9f3a1c2] Scaffold CityOps repo; add discovery note from Maria interview
2 files changed, 14 insertions(+)
That cycle — status, add, commit — is the heartbeat. git log --oneline shows the story so far. Write commit messages for the person reading them at midnight during an incident: what changed and why, not "fix stuff."
Myth 2: "Git is just backup for your code."
Backup is the least interesting thing Git does. Its real value on an engagement is evidence: what changed, when, why, and by whom — the audit trail Lisa wants, the rollback plan Maria needs, and the answer to Dev's "who touched the pipeline last Tuesday."
Branches and pull requests: how professionals share code
Once a second person touches the repo — Dev reviewing your pipeline, you fixing something while they refactor — everyone editing main directly becomes a collision sport. Branches give each line of work its own timeline:
$ git checkout -b fetch-311
Switched to a new branch 'fetch-311'
$ # ... write scripts/pull_311.sh, test it ...
$ git add scripts/pull_311.sh
$ git commit -m "Add 311 pull script with retry and timeout"
$ git checkout main
$ git merge fetch-311
Updating 9f3a1c2..b71d4e9
Fast-forward
scripts/pull_311.sh | 12 ++++++++++++
1 file changed, 1 insertion(+)
A pull request is that branch on a shared host like GitHub: you push it, someone reviews the diff, and only then does it merge into main. The PR is where the FDE rituals live — the description explains why, reviewers catch the chmod 777, Lisa sees exactly what enters the codebase. Protect main so every change arrives via a reviewed PR.
Break it: the merge conflict
You and Dev both edited notes/data-contract.md. Git can't decide whose version wins, so it stops and shows you both:
$ git merge dev/dedupe-notes
Auto-merging notes/data-contract.md
CONFLICT (content): Merge conflict in notes/data-contract.md
$ cat notes/data-contract.md
<<<<<<< HEAD
zip_code is authoritative for borough mapping.
=======
zip_code is UNRELIABLE; use lat/lon. See Dev's email 2026-10-02.
>>>>>>> dev/dedupe-notes
Don't panic — this is Git doing its job instead of silently overwriting someone. Edit the file to the correct resolution (Dev is right here — zip codes are unreliable, and this exact interrupt shows up in the Stage 2 milestone), remove the markers, then git add and git commit to finish the merge. Conflicts feel scary the first time; the dangerous alternative is two people editing without version control at all.
When you've broken it: disaster recovery
You will break things. Everyone does — the difference between a junior and a professional is how fast they recover and whether the client ever notices. Here is the recovery ladder, from common to catastrophic:
| Disaster | Recovery | Notes |
|---|---|---|
| Changed a tracked file, want it back | git checkout -- notes/plan.md | Only works if the change was never committed |
| Need to shelve work mid-task | git stash, then git stash pop | Your uncommitted changes, parked safely |
Bad commit already on main | git revert <commit> | Creates a new commit undoing it — history stays honest |
| Deleted a branch with work on it | git reflog, find the hash, git checkout -b rescue <hash> | The reflog records every move for ~90 days |
| Rewrote history you shouldn't have | git reflog again — almost everything is recoverable from there | The panic button that actually works |
The one command to treat with respect is git reset --hard: it discards uncommitted work permanently. Fine on your own throwaway branch; never on shared history, and never as a first resort. And the nuclear option — git push --force on a shared branch — rewrites history your teammates already pulled. Prefer revert: it undoes the change without rewriting the past, so everyone's repo stays consistent and the audit trail shows exactly what happened.
Try the reflog once, deliberately, so you trust it before you need it:
$ git checkout -b experiment
$ echo "test" > scratch.txt && git add scratch.txt && git commit -m "scratch"
$ git checkout main
$ git branch -D experiment # branch "deleted"...
$ git reflog
b71d4e9 HEAD@{0}: checkout: moving from experiment to main
a91f0c2 HEAD@{1}: commit: scratch
$ git checkout -b experiment a91f0c2 # ...and restored. Nothing was ever lost.
Making it production-safe
A repo on your laptop is a draft. A repo the client depends on needs a few guardrails, all cheap:
1. A .gitignore from day one. Secrets, credentials, local data dumps, and virtual environments must never enter history — removing them later is painful because Git remembers. CityOps starts like this:
$ cat .gitignore
# secrets — never commit these
.env
*.pem
config/secrets.yaml
# local data dumps (regenerable from the API)
data/raw/
# python
__pycache__/
*.pyc
.venv/
# OS noise
.DS_Store
2. No secrets in history, ever. If one slips in, rotate the credential first (assume it's compromised), then clean the history. Tell Lisa immediately — she would rather hear it from you in minute five than discover it in month five.
3. Small, honest commits on branches; reviewed PRs into main. This is your deploy pipeline's foundation — Stage 5's CI/CD literally triggers off this history. Concretely: one commit per logical change, with a message a stranger could act on. Compare "fix stuff" (useless at 2 AM) with "Retry 311 pull 3x on timeout; API dropped connections twice today" (tells the next reader exactly what happened and why the code looks that way).
4. The repo documents itself. A README that answers three questions — what is this, how do I run it, where does the data come from. Future-you, Dev, and Maria's successor all read the same file. Ours is four lines (see "Add it to CityOps" below) and that's enough for Stage 1; it grows as the project does.
Explaining it to the customer
Lisa's actual question, paraphrased from every engagement: "What did you change on our machine, and how do I undo it?" She deserves a plain-language answer, not a lecture on version-control internals. Here's the shape of one that works — adapt it, send it, keep it in the repo's notes folder:
Status update — CityOps environment setup (plain language)
Hi Lisa — here's everything I set up on the operations VM today, and how to reverse each piece:
1. A project folder (/home/ops/cityops) holding our scripts, notes, and data downloads — organized so anyone on the team can find things. Undo: delete the folder; nothing else on the machine references it.
2. File permissions tightened on the scripts Tom's team was sharing — they now run for the team but can't be modified by other accounts on the machine. Undo: I kept a list of the original settings in notes/.
3. A secure login key for my access — this replaces the shared password, is unique to me, and you can revoke it any time by removing one line from the server's authorized-keys file. I'll send you the exact line.
4. Version history for every file we produce from here on — think "track changes" for the whole project, so we can always show what changed, when, and why, or roll anything back.
Nothing was installed system-wide, no services were started, and no data left the machine. Happy to walk through any of it on a call.
Notice what that message does: it names every change, gives the undo for each, states what did not happen, and invites scrutiny. That is the customer-judgment half of the FDE skill stack, and it costs you ten minutes.
Terminal and Git underpin every stage of the loop — but they pay for themselves twice: once in Build, and again in Learn, when your history becomes the record the next engagement starts from.
Add it to CityOps
Everything in this post lands directly in the spine project. By the end, your CityOps repo exists and is honest:
$ mkdir -p cityops/{app,data/raw,scripts,notes} && cd cityops
$ git init -b main
$ cat > .gitignore <<'EOF'
.env
*.pem
__pycache__/
*.pyc
.venv/
data/raw/
.DS_Store
EOF
$ cat > README.md <<'EOF'
# CityOps
Service-request operations platform for the city team.
Data: NYC 311 open data (https://data.cityofnewyork.us/resource/erm2-nwe9.json)
Stage 1: scaffold + discovery. See notes/discovery-2026-10-02.md.
EOF
$ git add . && git commit -m "Initialize CityOps repo with structure, gitignore, README"
$ git log --oneline
9f3a1c2 Initialize CityOps repo with structure, gitignore, README
This is the repo that the Stage 1 milestone (Post 8) builds on: the status page, the deployed environments, and the first live 311 pull all commit here. When Tom asks Monday where the numbers came from, the answer is a commit hash, not a memory.
One new trick in that block, decoded: cat > .gitignore <<'EOF' means "take everything I type next and write it into .gitignore, stopping when I type EOF on its own line." It's a way to create a multi-line file without opening an editor — handy over SSH.
Where this post plugs into the roles around FDE:
Must know
- Navigate and manipulate files from the terminal:
pwd,ls -la,cd,mkdir -p,touch - Read and set permissions: the
rwxmask,chmod 755/644/600— and why777is never the answer - Chain tools with pipes to interrogate unfamiliar data:
curl | grep | sort | uniq -c - SSH with keys:
ssh-keygen,ssh-copy-id,~/.ssh/config - The Git heartbeat:
status → add → commit, branches, merging, PRs into a protectedmain - Recovery ladder:
checkout --,stash,revert,reflog— in that order of escalation
Useful later
jqfor serious JSON wrangling in the terminal (thegreppipeline above is a sketch;jqis the real tool)- SSH multiplexing and jump hosts for reaching machines behind client bastions
- Git hooks and commit-message conventions (e.g. Conventional Commits) once a team shares the repo
tmuxorscreenfor keeping long-running work alive on remote machines
Don't memorize this
- Every
lsortarflag —--helpand man pages exist for a reason - Git's internal object model — how commits link together under the hood — useful one day, not needed to ship
- Exotic merge strategies — resolve conflicts by talking to the human who wrote the other half
Field check
- To "make it work," you ran
chmod -R 777on a client's shared project directory. What could fail? How would you detect it? What would you tell the customer? - You pushed a bad commit to the shared
mainbranch and three teammates have already pulled. Do yourevert,reset --hard, or force-push? How do you decide? - Lisa emails: "List everything you installed or changed on our VM, and how I undo each item." Draft the reply in plain language — no jargon she can't forward to her manager.
What a good answer looks like
1. What could fail: any account on the machine can now modify or replace scripts and data — a compromised or careless account can inject code others will run, SSH will reject keys stored under world-writable directories, and secrets in that tree are readable by everyone. Detection: ls -lR showing rwxrwxrwx everywhere, or SSH key errors. Tell the customer: what you changed and why it was wrong, the corrected permissions you applied (755/644/600), and that you'll add a permission check to the setup notes so it doesn't recur. Honest, specific, no minimization.
2. revert. Once commits are shared, history is a contract — reset --hard plus force-push rewrites what your teammates already have, creating divergent repos and lost work. revert adds a new commit that undoes the change, so everyone's history stays consistent and the audit trail shows the mistake and its fix. Reserve history-rewriting for branches only you use.
3. A good reply names every change, pairs each with its undo, states what did not happen (nothing system-wide, no data exfiltrated), and invites review — like the status update in this post. If Lisa can forward it to her manager unchanged, you wrote it right.