Configuring Doom Emacs

January 2026 - 13 min read

Introduction

Doom Emacs is my daily driver. After years of tweaking, my configuration has evolved into something quite personal, a blend of Doom's sensible defaults and custom modules tailored to my workflow.

This post walks through the interesting parts of my Doom config (7e20928), explaining the design decisions and useful hacks I've accumulated.

I. Config structure

The configuration lives in ~/.doom.d/ and follows a modular structure:

  • init.el, module selection
  • config.el, main configuration
  • packages.el, package declarations
  • keybindings.el, all keybindings in one place
  • autoload/, lazy-loaded helper functions
  • modules/, personal Doom modules
  • templates/, file templates

The init.el declares which modules to load. Doom modules are listed first, with personal modules (prefixed :smallwat3r) placed at the end so their config runs after Doom's built-in modules, allowing overrides:

(doom! :completion
       (corfu +orderless +icons +dabbrev)
       (vertico +icons)

       :tools
       (lsp +eglot +booster)
       (magit +forge)
       ;; ...

       :smallwat3r
       evil-ext
       git-ext
       terminal
       ;; ...

       :config
       (default +bindings +smartparens))
copy

This approach keeps the main config clean. Each personal module is self-contained with its own config.el and optional packages.el. Custom functions are prefixed with my/ and variables with my-, making them easy to identify and avoiding conflicts with other packages.

II. Personal modules

Doom Emacs is built around a module system. Modules are self-contained units that bundle packages, configuration, and keybindings for a specific feature or language. You can enable or disable them in init.el, and Doom ships with dozens covering everything from programming languages to email clients. But the real power is that you can write your own modules following the same structure.

My personal modules live in modules/smallwat3r/. Each handles a specific concern. The ones ending in -ext are extensions of existing Doom modules:

  • evil-ext, Evil mode customizations and cursor colors
  • git-ext, Magit tweaks and SSH options for Git
  • terminal, vterm configuration and TRAMP helpers
  • modeline, custom mode-line with buffer count and search counter
  • highlighting, line numbers, rainbow delimiters, symbol overlay
  • debug, a minor mode that echoes command names for debugging
  • python-ext, Python-specific tooling (formatters, debugger, f-string toggle)
  • tools, misc utilities
  • etc

The full list is in my doom init file (7e20928).

III. Visual configuration

A core principle of my config is reducing visual clutter. Simple colors, a minimal mode-line, a stripped-down dashboard. Everything is designed to stay out of the way and let the code be the focus.

I use a custom theme called creamy, a warm, minimal theme designed to reduce visual noise and make long coding sessions easier on the eyes. It softens the harsh edges of typical syntax highlighting by stripping away excess color, leaving only the essential elements subtly emphasized. The result is a calm, focused editing experience, gentle on the eyes, yet readable and expressive where it counts. The font is Triplicate A Code, a proportional monospace font that's surprisingly readable for code.

Font configuration handles multiple scenarios, daemon mode, different operating systems, and fallbacks when fonts aren't installed:

(defun my/safe-font (fonts &rest spec)
  "Return a font-spec using the first available font in FONTS."
  (let ((available
         (seq-find (lambda (f)
                     (if (my/font-available-p f) t
                       (message "Warning: font not found: %s" f) nil))
                   fonts)))
    (when available
      (apply #'font-spec :family available spec))))

;; Pick font based on OS, with fallback
(setq doom-font
      (cond
       (IS-GPD
        (my/safe-font '("MonacoB" "Monospace") :size size))
       ((eq system-type 'gnu/linux)
        (my/safe-font '("Triplicate A Code" "MonacoB" "Monospace") :size size))
       ;; ...
       ))
copy

The IS-GPD constant detects if I'm running on a GPD Pocket (a tiny laptop), which needs different font sizes.

The dashboard is minimal, just a "MAIN BUFFER" heading with system info. It serves as a stable anchor point:

(defun my/dashboard-message ()
  (insert (concat "MAIN BUFFER\n"
                  my-title-emacs-version
                  " on " my-system-distro " (" my-system-os ")\n"
                  "Built for: " system-configuration)))

(setq +doom-dashboard-functions '(my/dashboard-message))
copy

The mode-line is tailored to be simple and easy on the eye. It shows buffer count, useful for knowing how many files you have open:

'(:eval (format "  b(%s)" (my/number-of-buffers)))
copy

Line numbers get colorized every 5th line, making it easy to estimate jump distances when using relative line numbers:

(setq display-line-numbers-minor-tick 5
      display-line-numbers-major-tick 5)

(custom-set-faces!
  '(line-number-minor-tick :foreground "orange" :weight bold))
copy

IV. Evil mode customization

Evil mode (Vim emulation) gets several tweaks. Cursor colors indicate the current state at a glance:

(setq evil-default-state-cursor  '(box "cyan3")
      evil-normal-state-cursor   '(box "cyan3")
      evil-insert-state-cursor   '(bar "green3")
      evil-visual-state-cursor   '(box "OrangeRed2")
      evil-replace-state-cursor  '(hbar "red2")
      evil-operator-state-cursor '(box "red2"))
copy

I use a non-standard keyboard layout, so many bindings have duplicates that work on both QWERTY and my layout. For example, window navigation:

;; Standard QWERTY
"wh" #'evil-window-left
"wj" #'evil-window-down
"wk" #'evil-window-up
"wl" #'evil-window-right

;; Custom layout equivalents
"wy" #'evil-window-left
"wn" #'evil-window-down
"wa" #'evil-window-up
"we" #'evil-window-right
copy

V. Keybindings philosophy

All keybindings live in keybindings.el, loaded at the end of config.el. This ensures they're applied last and makes them easy to find.

Module-specific bindings are guarded by modulep! checks, so they only load when the module is active:

(when (modulep! :smallwat3r terminal)
  (map!
   :leader
   (:prefix "o"
    :desc "Terminal"             "1" #'my/terminal-here
    :desc "Vterm at root"        "T" #'+vterm/here
    :desc "Toggle vterm at root" "t" #'+vterm/toggle
    ;; ...
    )))
copy

Some bindings use semicolon as a prefix for quick file operations:

  • ;d, save and close buffer
  • ;w, save buffer
  • ;q, close without saving

Insert mode gets arrow key equivalents on C-h/j/k/l, making small movements possible without leaving insert mode:

:map evil-insert-state-map
"C-h" #'evil-backward-char
"C-l" #'evil-forward-char
"C-k" #'evil-previous-line
"C-j" #'evil-next-line
copy

VI. Terminal integration

The terminal module configures vterm and TRAMP for remote file access.

a) SSH config parsing

TRAMP is configured to parse multiple SSH config files for host completion:

(defvar my-ssh-config-files
  '("~/.ssh/config"
    "~/.ssh/work"
    "~/.ssh/private")
  "List of SSH config files for TRAMP completion.")

(tramp-set-completion-function
 "ssh"
 (append
  (mapcar (lambda (f)
            (list 'tramp-parse-sconfig (expand-file-name f)))
          my-ssh-config-files)
  '((tramp-parse-sconfig "/etc/ssh_config")
    (tramp-parse-shosts "/etc/hosts")
    (tramp-parse-shosts "~/.ssh/known_hosts"))))
copy

b) Quick SSH connection

SPC o . prompts with read-file-name starting at /ssh:, triggering TRAMP's completion with all configured hosts:

(defun my/open-remote-conn ()
  "Open remote SSH connection with Tramp."
  (interactive)
  (find-file (read-file-name "Pick target: " "/ssh:")))
copy

c) TRAMP optimizations

TRAMP can be slow out of the box. These settings help:

(setq tramp-use-ssh-controlmaster-options nil)
copy

This tells TRAMP not to add its own ControlMaster options, assuming you have it configured in ~/.ssh/config:

Host *
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h-%p
    ControlPersist 600
copy

This reuses SSH connections across TRAMP sessions. Make sure the socket directory exists (mkdir -p ~/.ssh/sockets).

;; Cache remote file properties longer
(setq remote-file-name-inhibit-cache nil)

;; Disable VC checks on remote files
(setq vc-ignore-dir-regexp
      (format "\\(%s\\)\\|\\(%s\\)"
              vc-ignore-dir-regexp
              tramp-file-name-regexp))
copy

The first caches remote file properties longer, reducing round-trips. The second disables version control checks on remote files, which can be slow over SSH.

d) Remote file editing from shell

When opening vterm on a remote TRAMP connection, a hook injects an e shell function to open remote files from the remote shell in a local Emacs buffer:

# From remote shell:
$ e .bashrc      # Opens /ssh:user@host:.bashrc in Emacs
$ e src/main.rs  # Relative paths work too
copy

A helper extracts the TRAMP prefix from the current connection:

(defun my/vterm-tramp-base-path ()
  "Return the Tramp prefix without the directory."
  (let* ((vec (or (car (tramp-list-connections))
                  (when (tramp-tramp-file-p default-directory)
                    (tramp-dissect-file-name default-directory))))
         (method (and vec (tramp-file-name-method vec)))
         (user   (and vec (tramp-file-name-user vec)))
         (host   (and vec (tramp-file-name-host vec))))
    (when (and method host)
      (format "/%s:%s%s" method
              (if (and user (not (string-empty-p user)))
                  (concat user "@") "")
              host))))
copy

The hook injects the e function using vterm's OSC 51 escape sequence:

(defun my/vterm-buffer-hooks-on-tramp ()
  "Set up vterm for remote Tramp connections."
  (when (and (eq major-mode 'vterm-mode)
             default-directory
             (file-remote-p default-directory))
    (let ((tramp-base-path (my/vterm-tramp-base-path)))
      (rename-buffer (format "*vterm@%s*" tramp-base-path) t)
      (vterm-send-string
       (format
        "e() { local f=\"$1\"; [[ \"$f\" != /* ]] && f=\"$PWD/$f\"; \
printf '\\033]51;Efind-file %s:%%s\\007' \"$f\"; }\n"
        tramp-base-path)))
    (vterm-send-string "clear\n")))

(add-hook! 'vterm-mode-hook #'my/vterm-buffer-hooks-on-tramp)
copy

The injected e function works as follows: it takes a filename argument and checks if it's a relative path (doesn't start with /). If relative, it prepends $PWD to make it absolute. Then it emits an OSC (Operating System Command) escape sequence that vterm intercepts.

The sequence \033]51;E...;\007 is vterm's custom protocol: \033] starts the OSC, 51 is vterm's command code, E means "evaluate elisp", and \007 terminates. So printf '\033]51;Efind-file /ssh:host:/path\007' tells vterm to run (find-file "/ssh:host:/path") in Emacs. The TRAMP prefix is baked into the function when it's injected, so paths automatically resolve to the remote host.

VII. Git and Magit extensions

Magit gets several quality-of-life improvements. First, the h and l keys are unbound so they work for cursor movement in Evil mode:

(define-key magit-mode-map (kbd "l") nil)
(define-key magit-mode-map (kbd "h") nil)
copy

After committing, all COMMIT_EDITMSG buffers are killed automatically, including duplicates that accumulate when working with multiple repositories:

(add-hook! 'git-commit-post-finish-hook
  (dolist (buf (buffer-list))
    (when (string-prefix-p "COMMIT_EDITMSG" (buffer-name buf))
      (kill-buffer buf))))
copy

Git SSH commands from Emacs use custom options to improve reliability - forcing IPv4, setting timeouts, and detecting dropped connections:

(setenv "GIT_SSH_COMMAND" "ssh -4 \
  -o ConnectTimeout=10 \
  -o ServerAliveInterval=20 \
  -o ServerAliveCountMax=3 \
  -o TCPKeepAlive=yes \
  -o GSSAPIAuthentication=no \
  -o ControlMaster=no")
copy

Interactive rebase gets J/K bindings to move commits up and down, matching the rest of Evil's directional conventions:

(after! git-rebase
  (map! :map git-rebase-mode-map
        "K" #'git-rebase-move-line-up
        "J" #'git-rebase-move-line-down))
copy

VIII. Programming setup

I use Eglot for LSP instead of lsp-mode. It's built into Emacs 29+ and requires less configuration. Inlay hints are disabled as I find them distracting:

(add-hook! 'eglot-managed-mode-hook (eglot-inlay-hints-mode -1))
copy

Python uses basedpyright for type checking, with black and isort for formatting:

(set-eglot-client! 'python-mode '("basedpyright-langserver" "--stdio"))

(set-formatter! 'black
  '("black" "--quiet" "--line-length" "88" "--target-version" "py310" "-")
  :modes '(python-mode))
copy

A custom function toggles f-string prefixes on Python strings. With the cursor inside a string, SPC m f adds or removes the f prefix:

(defun my/python-toggle-fstring ()
  "Toggle f-string prefix on the current Python string literal."
  (interactive)
  (let* ((ppss (syntax-ppss))
         (string-start (nth 8 ppss)))
    (when (nth 3 ppss)  ; inside a string
      (save-excursion
        (goto-char string-start)
        (cond
         ((memq (char-before string-start) '(?f ?F))
          (delete-char -1))  ; remove f prefix
         (t
          (insert "f")))))))
copy

The PET package automatically finds the correct Python executable for each project, whether it's from virtualenv, poetry, or pyenv.

Flycheck runs on-demand rather than automatically. I find constant linting distracting, so it's triggered explicitly when needed:

(remove-hook! 'eglot-managed-mode-hook #'flycheck-eglot-mode)
(remove-hook! 'doom-first-buffer-hook #'global-flycheck-mode)
copy

IX. Utility functions

Custom helper functions live in autoload/, organized by purpose. Doom automatically loads these when called.

a) Buffer management

From autoload/buffer.el:

(defun my/save-and-close-buffer ()
  "Save, close current buffer and display a confirmation message."
  (interactive)
  (save-buffer)
  (message "Closed and saved: %s" (buffer-name))
  (kill-buffer (current-buffer)))

(defun my/kill-all-buffers-except-current ()
  "Kill all buffers except the current one."
  (interactive)
  (mapc 'my/kill-buffer (delete (current-buffer) (buffer-list)))
  (message "Killed all buffers except: %s" (current-buffer)))
copy

b) Insertions

From autoload/insert.el:

(defun my/insert-timestamp (&optional datetime)
  "Insert current date or date+time."
  (interactive "P")
  (let ((fmt (if datetime "%Y-%m-%d %H:%M" "%Y-%m-%d")))
    (insert (format-time-string fmt))))

(defun my/insert-email ()
  "Insert an email address from `my-email-addresses-alist'."
  (interactive)
  (let* ((keys (mapcar #'car my-email-addresses-alist))
         (choice (completing-read "Email: " keys nil t)))
    (insert (my/get-email choice))))
copy

c) Process management

From autoload/process.el, a fuzzy process killer showing CPU and memory usage:

(defun my/fuzzy-kill-process ()
  "Fuzzy-pick a system process and send SIGKILL."
  (interactive)
  (let* ((fmt-item
          (lambda (pid)
            (let ((a (process-attributes pid)))
              (when a
                (let* ((name (alist-get 'comm a))
                       (pcpu (or (alist-get 'pcpu a) 0.0))
                       (rss  (alist-get 'rss a))
                       (rss-mb (if (numberp rss) (/ rss 1024.0) 0.0)))
                  (cons (format "%-20s %6d  %7.1fMB  %5.1f%%"
                                (or name "?") pid rss-mb pcpu)
                        pid))))))
         (items (delq nil (mapcar fmt-item (list-system-processes))))
         (choice (completing-read "Kill process: " (mapcar #'car items) nil t))
         (pid (cdr (assoc choice items))))
    (signal-process pid 'kill)
    (message "Killed: %s" choice)))
copy

d) Search helpers

From autoload/search.el:

(defun my/find-file-in-dotfiles ()
  "Find file in my dotfiles."
  (interactive)
  (doom-project-find-file my-dotfiles-dir))

(defun my/where-am-i ()
  "Display the current buffer's file path or buffer name."
  (interactive)
  (message (if (buffer-file-name)
               (buffer-file-name)
             (concat "buffer-name=" (buffer-name)))))

(defun my/vertico-search-project-symbol-at-point (&optional arg)
  "Performs a live project search for the thing at point."
  (interactive)
  (+vertico/project-search arg (thing-at-point 'symbol)))
copy

e) Window management

From autoload/window.el, fine-grained scrolling and resizing, moving 3 lines or 5 columns at a time rather than Emacs defaults:

(defun my/scroll-up ()
  "Scroll view up by 3 lines."
  (interactive)
  (scroll-down 3))

(defun my/scroll-down ()
  "Scroll view down by 3 lines."
  (interactive)
  (scroll-up 3))

(defun my/enlarge-window-horizontally ()
  "Enlarge window horizontally by 5 columns."
  (interactive)
  (enlarge-window-horizontally 5))
copy

X. Useful hacks

a) Wayland environment propagation

When running Emacs as a daemon under Sway, subprocesses (like Magit's git hooks) can't access Wayland because the daemon started without those environment variables. This hook pulls Wayland vars from new emacsclient frames into the daemon's environment:

(when (and (daemonp) (eq system-type 'gnu/linux))
  (defun my/update-wayland-env-from-frame ()
    "Update WAYLAND_DISPLAY and SWAYSOCK from the current frame."
    (when-let ((runtime-dir (getenv "XDG_RUNTIME_DIR")))
      (when (directory-files runtime-dir nil "^sway-ipc\\..*\\.sock$")
        (dolist (var '("WAYLAND_DISPLAY" "SWAYSOCK"))
          (when-let ((val (getenv var (selected-frame))))
            (setenv var val))))))
  (add-hook! 'server-after-make-frame-hook
             #'my/update-wayland-env-from-frame))
copy

b) Disabled packages

Several Doom defaults are disabled to reduce bloat or avoid conflicts:

(disable-packages!
 lsp-python-ms  ; prefer basedpyright
 pipenv         ; prefer poetry
 nose           ; prefer pytest
 lsp-ui         ; prefer eglot's minimal approach
 solaire-mode   ; interferes with my theme
 evil-escape)   ; not needed with my custom keyboard
copy

c) Abbreviations

Emacs abbreviations are quick text expansions. I store mine in abbrev_defs.el, a file Emacs reads on startup and updates when you define new abbreviations interactively. Typing ifn in Python expands to the main guard pattern, pdb inserts a debugger breakpoint:

(define-abbrev-table 'python-mode-abbrev-table
  '(("ifn" "if __name__ == \"__main__\":\n    " nil 0)
    ("pdb" "import pdb; pdb.set_trace()  # debug" nil 0)
    ("shb" "#!/usr/bin/env python3\n" nil 0)))
copy

d) Buffer management helpers

Handle common operations that Emacs makes awkward. Killing a vterm buffer normally prompts about the running process, this wrapper suppresses that:

(defun my/kill-buffer (&optional buffer)
  "Kill current buffer without prompts for term/vterm/eshell."
  (interactive)
  (let ((buf (or buffer (current-buffer))))
    (with-current-buffer buf
      (when (derived-mode-p 'vterm-mode 'term-mode 'eshell-mode)
        (set-buffer-modified-p nil)
        (when-let ((proc (get-buffer-process buf)))
          (set-process-query-on-exit-flag proc nil))))
    (kill-buffer buf)))
copy

e) Custom themes

I maintain two Emacs themes: creamy and simplicity. Both are available on MELPA, and can be loaded via Doom's package system:

(package! creamy-theme)
(package! simplicity-theme)
copy

~~~

Wrapping up

This configuration represents years of incremental refinement. The modular structure makes it easy to enable or disable features without breaking everything, and keeping keybindings in a single file makes them discoverable. Emacs rewards investment, the more you shape it to your workflow, the more it becomes an extension of thought rather than a tool you fight against.

← back