Editing Files on a Server: vim Essentials, nano, sudoedit and Changing Config Files Safely
Edit config files on a server with sudoedit instead of sudo vim, take a backup first, read the change with diff -u, test it with nginx -t or sshd -t and use reload instead of restart; a reload with a broken config keeps the site up, a restart takes it down. We cover vim's modes and essential commands, nano, why the editor falls back to nano under sudo, the CRLF and .swp pitfalls and tracking /etc in Git with etckeeper, with real output.
Version note: The examples in this post were run on a test server with systemd running Ubuntu Server 24.04.5 LTS (vim 9.1, nano 7.2, sudo 1.9.15p5, Nginx 1.24, OpenSSH 9.6p1, etckeeper 1.18.20) and in the Ubuntu 26.04.1 LTS container image (vim 9.1, nano 8.7.1, sudo-rs 0.2.13). The current upstream vim release is 9.2; Ubuntu ships 9.1 with patches in both LTS releases, and the commands in this post are the same in both versions.
Guess First
You connected to the server of Bookstore, the online bookstore used as the shared example across this site's series, and since you are used to vim, you added this to the end of ~/.bashrc:
$ tail -n 1 ~/.bashrc
export EDITOR=vim
$ echo $EDITOR
vim
Then you run four commands in the same session: crontab -e, sudo systemctl edit nginx, sudo visudo -f /etc/sudoers.d/bookstore-deploy and sudoedit /etc/nginx/sites-available/bookstore. Which of them open vim, and which open a different editor? The answer is in the "Which Editor Opens?" section.
Editing files on the server came up in every post of this series: systemctl edit and crontab -e in the fifth post, visudo in the third, sshd_config in the eleventh. But we never talked about the editor itself. This post covers enough vim to get work done without getting lost, nano, how the editor is chosen, and the order of steps for changing a config file without breaking the server.
Surviving vim: Modes, Saving and Quitting
Most people who open vim for the first time cannot get out, because what they type is read as commands, not as text. vim works with modes:
- Normal mode: the mode you start in. Keys are commands:
dddeletes a line,uundoes. Wherever you are,Escbrings you back here. - Insert mode: entered with
i(before the cursor),a(after it),o(new line below) orO(new line above); what you type here is text.-- INSERT --appears at the bottom of the screen. - Command-line mode: opened with
:in normal mode; the commands for saving, quitting and replacing are typed here.
There are four ways out, and all of them start with Esc: :w saves, :q quits (and refuses if there are unsaved changes), :wq saves and quits, :q! throws the changes away and quits. If you think you broke a file by accident, :q! is the safest exit. vim's own tutorial opens with vimtutor; it takes half an hour and has you try everything below by hand.
Moving, Searching, Replacing and Copying
Almost every command you need on a server is in this table. All of them are typed in normal mode; the ones starting with : appear at the bottom of the screen and run with Enter.
| Task | Command |
|---|---|
| Start of file, end of file, line 8 | gg, G, :8 |
| Start of line, end of line, word by word | 0, $, w / b |
| Search forward, next and previous match | /proxy_pass, n, N |
| Delete, copy, paste a line below | dd, yy, p (above: P) |
| Undo, redo | u, Ctrl-r |
| Replace on the current line | :s/old/new/ |
| Replace in the whole file, asking for each one | :%s/old/new/g, :%s/old/new/gc |
| Line numbers, append at end of line | :set number, A |
dd, yy and the movement commands take a count: 3dd deletes three lines, 5j moves five lines down. The % in :%s means "all lines"; without it the replacement only happens on the line the cursor is on.
Indentation breaking on paste is a separate problem. If the text you paste from the terminal reaches vim as if it were typed key by key, vim tries to keep the previous line's indentation on every new line and adds the spaces you pasted on top. When I pasted seven lines into a YAML file, this was the result:
services:
api:
image: bookstore-api:1.4.2
ports:
- "127.0.0.1:5000:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
Indentation carries meaning in YAML, so this is now a different file. Current vim and terminals solve this with bracketed paste, where the terminal wraps pasted text in special markers: vim takes the pasted block as one piece of text instead of typed keys, and the same seven lines arrive intact. If your terminal does not support it or a tool in between gets in the way, type :set paste before pasting and :set nopaste afterwards; the same paste came out correctly with that setting too.
nano: The Shortcuts Are at the Bottom of the Screen
If you do not want to learn vim, nano is the default editor on Ubuntu. It lists its shortcuts at the bottom of the screen; ^ means Ctrl and M- means Alt:
GNU nano 7.2 /etc/sudoers.d/bookstore-deploy.tmp
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart bookstore-api, /usr/bin/systemctl >
...
^G Help ^O Write Out ^W Where Is ^K Cut ^T Execute ^C Location
^X Exit ^R Read File ^\ Replace ^U Paste ^J Justify ^/ Go To Line
For daily use, these are enough: Ctrl-O saves (it asks you to confirm the file name, Enter), Ctrl-X exits (and asks if there are unsaved changes), Ctrl-W searches, Ctrl-\ finds and replaces, Ctrl-K cuts a line, Ctrl-U pastes, Alt-U undoes. nano -l shows line numbers, which helps when a config error says "line 9". The > at the end of a line means the line does not fit on the screen; by default nano scrolls a long line instead of wrapping it. Ubuntu 26.04 ships nano 8.7.1; the shortcuts above are the same.
Which Editor Opens? EDITOR, VISUAL and sudoedit
The answer to the guess. I ran the four commands in the same session and looked at the screen that opened:
$ crontab -e
"/tmp/crontab.jw2NB9/crontab" 23L, 889B 1,1 All
$ sudo systemctl edit nginx
GNU nano 7.2 /etc/systemd/system/nginx.service.d/.#override.conf5118365f0d75566b
$ sudo visudo -f /etc/sudoers.d/bookstore-deploy
GNU nano 7.2 /etc/sudoers.d/bookstore-deploy.tmp
$ sudoedit /etc/nginx/sites-available/bookstore
"/var/tmp/bookstore.PsK4wZ7q" 24L, 860B 1,1 Top
The screen with the file name, line and byte counts on its bottom line is vim; the one with nano's name and version at the top is nano. crontab -e and sudoedit opened vim; the two commands starting with sudo opened nano. The reason is that sudo cleans the environment: Defaults env_reset in Ubuntu's sudoers file does not carry your EDITOR into a command that runs as root. systemctl edit looks at SYSTEMD_EDITOR first, then EDITOR and VISUAL; when it finds none of them, it runs the editor command. On Ubuntu that is an alternatives link too, and it puts nano (priority 40) ahead of vim (30). visudo falls back to nano the same way: Ubuntu's sudo package is built with /usr/bin/editor as the default editor.
sudoedit works differently, and that is why it is the right way to edit files on a server. I looked at the processes while the editor was open:
$ ps -o user,pid,args -C vim,sudo,sudoedit,nano
USER PID COMMAND
root 1110 sudoedit /etc/nginx/sites-available/bookstore
root 1111 sudoedit /etc/nginx/sites-available/bookstore
ada 1112 vim /var/tmp/bookstore.PsK4wZ7q
sudo creates a temporary copy of the file on your behalf, runs the editor not as root but as ada, with your own environment, and when you save and quit it writes the copy back into place. It picks the editor in the order SUDO_EDITOR, VISUAL, EDITOR; your .vimrc applies too. The difference from sudo vim:
$ sudo vim -es -c "!id" -c "q" /etc/hosts
uid=0(root) gid=0(root) groups=0(root)
Every shell command run from inside sudo vim, every plugin and every :w other-file runs with root privileges. If you want to allow someone in sudoers to edit just one file, this amounts to giving them full root; with sudoedit the editor is not root. Two of sudoedit's restrictions show up right away with Nginx:
$ sudoedit /etc/nginx/sites-enabled/bookstore
sudoedit: /etc/nginx/sites-enabled/bookstore: editing symbolic links is not permitted
$ sudoedit /tmp/x.conf
sudoedit: /tmp/x.conf: editing files in a writable directory is not permitted
The file in sites-enabled is a symlink; edit the real file, the one in sites-available. It also refuses a file in a directory the user can write to, because there it would be possible to replace the file with a symlink and make root write to a different file. sudo-rs in Ubuntu 26.04 supports sudoedit as well and refuses the same two cases, only with different messages (sudo: error opening /tmp/x.conf: cannot open a file in a path writable by the user).
If you want your own editor for commands run with sudo, pass the variable to the command explicitly or change the system default:
$ sudo --preserve-env=EDITOR systemctl edit nginx
"/etc/systemd/system/nginx.service.d/.#override.conf9fa110737e04ecc9" [noeol] 40L, 1435B 4,0-1 Top
$ sudo EDITOR=vim visudo -f /etc/sudoers.d/bookstore-deploy
"/etc/sudoers.d/bookstore-deploy.tmp" 1L, 157B 1,1 All
# to change the default for all users: sudo update-alternatives --config editor
crontab, on the other hand, reads VISUAL before EDITOR. If the two differ, set both to the same value to avoid surprises.
Changing a Config Safely: Backup, diff, Test, Reload
In the eighth post we saw that 200 requests left 200 TIME_WAIT connections on the Kestrel side: by default Nginx opens a new connection to the upstream for every request. The fix is an upstream block with keepalive in the Nginx config. The order is the same for every config change: take a backup, edit, read the diff, test, then reload.
$ sudo cp -a /etc/nginx/sites-available/bookstore /etc/nginx/sites-available/bookstore.bak
$ sudoedit /etc/nginx/sites-available/bookstore
The change I made in vim comes down to these keys:
/proxy_pass
:s/127.0.0.1:5000/bookstore_api/
o proxy_http_version 1.1;
o proxy_set_header Connection "";
gg
/^server
O
upstream bookstore_api {
server 127.0.0.1:5000;
keepalive 16
}
:wq
(Esc after each o/O line and after the block.) After saving, look at what you changed. diff -u shows the old and new file in unified format, the way you are used to from Git; lines starting with - were removed and lines starting with + were added:
$ diff -u bookstore.bak bookstore
--- bookstore.bak 2026-11-15 07:04:01.416420000 +0000
+++ bookstore 2026-11-15 07:08:59.246163013 +0000
@@ -3,6 +3,11 @@
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'$request_time $upstream_response_time $request_id';
+upstream bookstore_api {
+ server 127.0.0.1:5000;
+ keepalive 16
+}
+
server {
listen 80 default_server;
server_name _;
@@ -15,7 +20,9 @@
error_log /var/log/nginx/bookstore.error.log;
location / {
- proxy_pass http://127.0.0.1:5000;
+ proxy_pass http://bookstore_api;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
$ sudo nginx -t
2026/11/15 07:09:00 [emerg] 2088#2088: unexpected "}" in /etc/nginx/sites-enabled/bookstore:9
nginx: configuration file /etc/nginx/nginx.conf test failed
I forgot the semicolon after keepalive 16, and Nginx noticed the error on the next line, when it saw the }; the reported line is not always the mistake itself but the place where the parser got stuck. proxy_http_version 1.1 and the empty Connection header are required for keepalive to work: by default Nginx talks to the upstream with HTTP/1.0 and Connection: close. What if I had skipped nginx -t?
$ sudo systemctl reload nginx
Job for nginx.service failed.
See "systemctl status nginx.service" and "journalctl -xeu nginx.service" for details.
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/health
200
$ sudo systemctl restart nginx
Job for nginx.service failed because the control process exited with error code.
See "systemctl status nginx.service" and "journalctl -xeu nginx.service" for details.
$ curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1/health
curl: (7) Failed to connect to 127.0.0.1 port 80 after 0 ms: Couldn't connect to server
000
The reload failed, but the site kept running: when Nginx cannot load the new config, it carries on with the old workers. A restart stops first and then starts, so with the broken config the site went down. That is why you use reload, not restart, for config changes, and chain the reload to the test. I fixed the line in vim with :8, A;, Esc, :wq:
$ sudo nginx -t && sudo systemctl start nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ for i in $(seq 1 200); do curl -s -o /dev/null http://127.0.0.1/health; done
$ ss -tan state time-wait "( sport = :5000 or dport = :5000 )" | tail -n +2 | wc -l
0
$ ss -tan state established "( dport = :5000 )" | tail -n +2 | wc -l
10
No TIME_WAIT is left on the Kestrel side; the Nginx workers reuse the few connections they keep open. With this post, deploy/linux/nginx/bookstore.conf in the repo has this content.
Pay attention to where you put the backup. The .bak file in sites-available is harmless, because Nginx only reads sites-enabled. If you put the same backup into sites-enabled, Nginx loads it too and the test trips over everything being defined twice: when I tried it, the first error was duplicate "log_format" name "bookstore" in /etc/nginx/sites-enabled/bookstore.bak:4. sshd_config.d only reads files ending in .conf, and /etc/sudoers.d only files without a dot in their name; if you do not know the rule of each directory, keep backups outside it.
sshd_config and Not Locking Yourself Out
In Nginx a broken config at worst takes the site down; in sshd_config it takes away your access to the server. The rule from the eleventh post applies here too: open a second SSH session before the change and test while that session is open. sshd -t reads the files without reloading:
$ sudo sshd -t
/etc/ssh/sshd_config.d/20-typo.conf: line 1: Bad configuration option: PasswordAuthentcation
/etc/ssh/sshd_config.d/20-typo.conf: terminating, 1 bad configuration options
After a restart with this error sshd does not start and no new connection can be made; as we saw in the eleventh post, open sessions are not affected. So do not close your open session before you have seen that a new session can log in.
Files prepared on Windows and copied to the server carry a separate risk: their line endings may be CRLF (\r\n). I tried which tools cope with that. Nginx, sshd and systemd units swallowed the \r at the end of the line without trouble; visudo -c rejected the file. The sneakiest one is cron:
$ file report.cron
report.cron: ASCII text, with CRLF line terminators
$ crontab report.cron
$ ls -b /tmp | grep report
bookstore-report.log\r
$ sed -i "s/\r$//" report.cron && file report.cron
report.cron: ASCII text
The job ran without an error, but it wrote its log into a file whose name ends with an invisible \r; in a plain ls output you see what looks like the right file name and start wondering why the log is not growing. When vim opens such a file it shows [dos] on the bottom line; :set ff=unix and :w fix the line endings. The permanent fix is at the source: we added a .gitattributes to the repo, so files under deploy/ come with LF even when checked out on Windows:
# .gitattributes
# Files that go to Linux servers always use LF line endings, even on a Windows checkout
deploy/** text eol=lf
*.sh text eol=lf
Then there is the .swp warning. When I opened the same file with vim in a second session, I saw this:
E325: ATTENTION
Found a swap file by the name "~/.notes.txt.swp"
owned by: ada dated: Sun Nov 15 07:11:28 2026
file name: ~ada/notes.txt
modified: no
user name: ada host name: lab
process ID: 2649 (STILL RUNNING)
...
Swap file "~/.notes.txt.swp" already exists!
[O]pen Read-Only, (E)dit anyway, (R)ecover, (Q)uit, (A)bort:
vim keeps a swap file for every file being edited. If it says STILL RUNNING, the file is open in another session: quit with Q and find out who has it open. If you continue with (E)dit anyway, two editors hold the same file and whoever saves last deletes the other's changes. If the process is not running, the previous session dropped; use R to recover the unsaved changes, then delete the swap file. One warning: because sudoedit opens a copy with a different name in /var/tmp each time, this warning never appears when two people edit the same config with sudoedit. If a team logs into one server at the same time, that makes the Git history in the next section even more valuable.
Tracking /etc with Git: etckeeper
Backup files pile up over time, and nobody remembers which one was taken and why. etckeeper turns /etc into a local Git repository:
$ sudo apt install -y etckeeper
...
Setting up etckeeper (1.18.20-2) ...
Created symlink /etc/systemd/system/multi-user.target.wants/etckeeper.timer → /usr/lib/systemd/system/etckeeper.timer.
...
Initialized empty Git repository in /etc/.git/
[master (root-commit) 0a78784] Initial commit
Author: ada <ada@lab>
629 files changed, 16697 insertions(+)
...
$ sudo rm /etc/nginx/sites-available/bookstore.bak
$ sudo git -C /etc status --short
D nginx/sites-available/bookstore.bak
$ sudo etckeeper commit "nginx: remove bookstore backup after keepalive change"
[master b82d5ea] nginx: remove bookstore backup after keepalive change
Author: ada <ada@lab>
2 files changed, 25 deletions(-)
$ sudo apt-get install -y -q ufw
...
[master 8587b2a] committing changes in /etc made by "apt-get install -y -q ufw"
$ sudo git -C /etc log --oneline
8587b2a committing changes in /etc made by "apt-get install -y -q ufw"
b82d5ea nginx: remove bookstore backup after keepalive change
0a78784 Initial commit
Now git -C /etc diff shows changes made by hand that are not committed yet, and git log shows who changed what and when. etckeeper adds a hook to apt: before every package installation it commits pending changes, and afterwards it commits the changes the package made, separately. In addition, etckeeper.timer commits uncommitted changes 15 minutes after boot and then once a day. The ada in the commit is the user who called sudo; even on servers where people work as root, the owner of the change is visible.
Careful: this repository also contains /etc/shadow and /etc/bookstore/secrets.env. /etc/.git is a directory only root can read (700) and must stay that way; never push this repository to a remote such as GitHub. etckeeper is for change history, not the source of your config. The source is, as throughout this series, the deploy/linux/ directory in the application's repo.
Production Notes
The question to answer before opening an editor in production: will this change also go into the repo? A change made by hand on the server that never reaches the repo is lost on the next deploy or on the move to a new server we described in the thirteenth post. In an emergency, fix it on the server, but write it into the repo the same day.
Memorize the test commands: nginx -t, sshd -t, visudo -cf (for sudoers.d files) and systemd-analyze verify (for unit files). If you change a config that has no test command, first try starting the service by hand against a copy.
Verify a reload by its result. Even if systemctl reload returns zero, the real evidence is a health check with curl and the first lines in the log (sixth post).
Common Pitfalls
Editing configs with sudo vim. The editor runs as root, root's settings apply instead of yours, and a typo that breaks the file is written straight into place. Use sudoedit.
Restarting without a test. A reload with a broken config protects the site; a restart takes it down. Keep nginx -t && systemctl reload nginx on one line.
Putting the backup inside the config directory. sites-enabled/bookstore.bak becomes a second site that Nginx loads.
Not noticing a CRLF file. The tools that do not complain are the most dangerous; file and vim's [dos] marker are the first check.
Pressing (E)dit anyway at the .swp warning. First find out who has the file open.
A Small Experiment
★☆☆ Finish vimtutor, then on a test server open a copy of /etc/nginx/sites-available/default in vim and replace with :%s/80/8080/gc, confirming each match one by one. Undo everything with u.
★★☆ Find two different ways to make sudo visudo open vim (one just for you, one for the whole system) and use etckeeper to see which file each of them changes.
★★★ Write a deliberately broken config for Nginx, then compare the behavior of systemctl reload and systemctl restart using the output of journalctl -u nginx. Also check what happens to a request that is in flight during the reload (the slow report endpoint from the fourth post).
We have seen how to change a config file; in the next post we work with the files themselves: finding and deleting old backups with find and xargs, building a release package with tar and zstd, moving it to the server with rsync and verifying it with a checksum.