Putting a leash on that emacs config
Or: Yes there is an org mode feature for this
Creating a personal emacs configuration can mean different things to different people. The take I see most often is that you start your configuration and then watch it grow and grow over time until eventually you become too scared to touch it because you can´t even quite remember why some snippets of elisp are in there and you don’t want to spend the energy to retrace your steps.
Either that or you just go and pick a framework where someone else has spent the time to craft that configuration for you. For the most time.
For me personally my my config shrunk over time the more I discovered features that were already there and ready to be used. And I found that simply putting it all in a single org file makes for a comfortable way to keep (almost) everything in a single place. Description , code and neat categories.
So here are some notes on how I approach this.
Org mode and Babel
Org-mode’s org-babel-tangle is a simple idea: take all the source code blocks in an Org file and write them out to files. A block annotated :tangle yes gets appended to the target file. The target is either specified per-block with :tangle path/to/file.el, or set globally for the whole document via a #+PROPERTY header.
When you call org-babel-load-file on an Org file, Emacs tangles it to a .el file (by default, same name, same directory) and then loads that .el file. That’s the whole mechanism.
This matters because it means your config is never interpreted or eval’d directly from the Org file it’s compiled to clean Elisp first. Startup time stays sane. Byte-compilation works normally.
The Bootstrap: init.el
Your init.el should do exactly one thing: load the config. Nothing else belongs there.
;; -*- lexical-binding: t; -*-
;; Load the literate configuration.
(org-babel-load-file
(expand-file-name "config.org" user-emacs-directory))
That’s it. The first time Emacs runs this, it calls org-babel-load-file, which tangles config.org to config.el, then loads config.el. Every subsequent start skips the tangle if config.el is newer than config.org.
The bootstrap is the contract: init.el is infrastructure, config.org is the actual configuration.
A small note on lexical-binding: set it to t in both init.el and declare it in your Org file’s first code block. Dynamic binding is a footgun from 1985. You don’t want it.
config.org: Structure and Headers
Create ~/.emacs.d/config.org. The top of the file declares the tangle target for all source blocks: Plaintext
#+TITLE: Emacs Configuration
#+AUTHOR: Your Name
#+PROPERTY: header-args:emacs-lisp :tangle config.el :lexical t
* Preamble
The #+PROPERTY line means every emacs-lisp source block will tangle into config.el by default, with lexical binding enabled. You never have to repeat that per-block.
Now you can write your configuration as an actual document: Plaintext
* Startup Performance
Increase the GC threshold during startup. We'll reset it after.
#+begin_src emacs-lisp
(setq gc-cons-threshold (* 50 1000 1000))
(add-hook 'emacs-startup-hook
(lambda ()
(setq gc-cons-threshold (* 2 1000 1000))))
#+end_src
* UI
Remove the things that don't belong.
#+begin_src emacs-lisp
(setq inhibit-startup-screen t)
(menu-bar-mode -1)
(tool-bar-mode -1)
(scroll-bar-mode -1)
#+end_src
The prose around the code isn’t decoration. It’s the answer to “why did I do this?” — a question you will ask yourself six months from now.
To tangle manually: C-c C-v t in the Org buffer. To always tangle on save, add this to your Org buffer’s local variables, or hook into after-save-hook.
Taming custom-set-variables
This is a part I still do not like about emacs, at all.
When you use Emacs’s built-in Customize interface or when packages call customize-set-variable under the hood Emacs writes a custom-set-variables block to your init file. Emacs itself says this in the generated code:
;; custom-set-variables was added by Custom.
;; If you edit it by hand, you could mess it up, so be careful.
;; Your init file should contain only one such instance.
;; If there is more than one, they won't work right.
So Emacs is writing to your config, warning you not to edit what it wrote, and also warning you that having two of these blocks breaks things. Wonderful.
The fix is to redirect the custom-file variable before any packages load. Put this early in your config.org, ideally as the first code block:
* Housekeeping
** Redirect the Custom File
Emacs will write its Customize state to =custom.el=, not to =config.el=.
We load it if it exists, but we don't commit it.
#+begin_src emacs-lisp
(setq custom-file (expand-file-name "custom.el" user-emacs-directory))
(when (file-exists-p custom-file)
(load custom-file 'noerror))
#+end_src
Now Emacs writes all its Customize output to custom.el. Your config.org (and the tangled config.el) stays clean. You can add custom.el to your .gitignore it contains machine-generated state, not configuration you wrote.
A more aggressive option: point custom-file at a temporary file. Then Customize state is never persisted at all.
;; Nuclear option: discard all Customize state on exit
(setq custom-file (make-temp-file "emacs-custom-"))
This is appropriate if you configure everything through setq and use-package :custom blocks and don’t want Emacs touching your files. Use it if you’re certain; use the custom.el redirect if you’re not.
Backup files everywhere
Emacs creates several categories of transient files. By default, they land wherever the file being edited lives.
- Backup files (file~): created on first save after opening.
- Auto-save files (#file#): created periodically while editing, deleted on clean save.
- Lock files (.#file): created when a buffer is open, to warn other Emacs instances.
Compared to the unsolicited custom-set-variables sneaking into your config files, these are very useful features. The problem is they scatter across your entire filesystem. Every git repository you edit gets these files; every project directory fills up with tilde-suffixed debris.
But there is a clean way to handle them:
** Contain the Mess
All transient files go under =~/.emacs.d/tmp/=. Never in the filesystem.
#+begin_src emacs-lisp
;; Backup files (file~)
(setq backup-directory-alist
`(("." . ,(expand-file-name "tmp/backups/" user-emacs-directory))))
;; Auto-save files (#file#)
;; Make the directory first — Emacs won't create it for you.
(make-directory (expand-file-name "tmp/auto-saves/" user-emacs-directory) t)
(setq auto-save-list-file-prefix
(expand-file-name "tmp/auto-saves/sessions/" user-emacs-directory)
auto-save-file-name-transforms
`((".*" ,(expand-file-name "tmp/auto-saves/" user-emacs-directory) t)))
;; Lock files (.#file) cannot be moved, so disable them.
;; The risk is negligible unless you run multiple Emacs instances on the same files.
(setq create-lockfiles nil)
#+end_src
The make-directory call with t as the second argument creates parent directories as needed, silently, even if the directory already exists. You need it because auto-save-file-name-transforms will fail silently if the target directory doesn’t exist.
While you’re at it, improve the backup behavior. By default Emacs keeps only the most recent backup. This misses the point — the value of backups is history:
** Keep Backup History
#+begin_src emacs-lisp
(setq backup-by-copying t
kept-new-versions 10
kept-old-versions 5
delete-old-versions t
version-control t)
#+end_src
With version-control t, your backup directory accumulates numbered versions of every file you edit. It’s a cheap safety net, separate from git, that has saved me more than once.
There’s a package called no-littering that automates all of this and more it knows about dozens of packages and redirects their cache files too. 1 Using it is entirely reasonable. But understanding what it does first which is what you just did is better.
Version Control
It’s nice to have ~/.emacs.d/ be a git repository.
cd ~/.emacs.d
git init
The .gitignore:
# Tangled output — generated from config.org
config.el
config.elc
# Package installations — large, reproducible
elpa/
# Machine-generated Customize state
custom.el
# All the transient mess we just contained
tmp/
# Emacs native compilation cache
eln-cache/
What you do commit:
- init.el — the bootstrap
- config.org — your actual configuration, the source of truth
- Vendored files or snippets you maintain yourself
The tangled config.el is generated output. Committing generated output alongside its source is the same mistake as committing compiled binaries. When someone (future you, or a new machine) clones the repo, init.el runs org-babel-load-file, which tangles config.org into config.el fresh. The loop is complete.
If you want to keep the backup history in version control — which is occasionally useful — commit tmp/backups/ by removing it from .gitignore. More likely you want it ignored: it’s noise, and git’s own history is a better mechanism for tracking intentional changes. The
Finished Shape
After all of this, your ~/.emacs.d/ looks like:
~/.emacs.d/
├── init.el
├── config.org
├── custom.el <- This gets generated
├── tmp/
│ ├── backups/ <- backup files, gitignored
│ └── auto-saves/ <- auto-save files, gitignored
└── elpa/ <- installed packages, gitignored
init.el rarely changes. config.org is where you live. Everything else is either generated or transient.