I’m not sure how it took me this long to discover1 atuin but I’ve been missing out for sure. Atuin is essentially (much) “better shell history” in a bunch of ways, as it:
- uses a database to enable more kinds of queries on the history
- has per-directory (or workspace) contextual filters, to better limit what is shown
- syncs your shell history across devices, via an end-to-end encrypted sync server
That last feature was one of the things I was most excited about, although 2 is really great as well in practice. While there is a public sync server available, I thought I should run and host my own, given that’s an option and I’m that sort of person.
So, we’re setting up a self-hosted Atuin sync server reachable via Tailscale, running in containers (Podman, but Docker would work), with 1Password providing secrets management, running on macOS. The completed configuration is available on GitHub , if you want to skip ahead. The documentation from Atuin is pretty thorough, but I did have to figure out a bit more glue, hence this post.
Containers (Podman, Docker etc.)#
I use Podmanas most container workloads don’t need root. In this particular
setup, we’ll deploy 3 containers via podman-compose
:
- Atuin server
- the Postgres database for the Atuin server
- Tailscale routing so we can reach the Atuin server from our tailnet
Setting up the Postgres and Atuin containers is pretty well-covered in the official self-hosting docs , though I did have to figure out a small thing and ended up submitting a PR to fix the upstream docs.
Atuin server#
Annotated container config:
atuin:
image: ghcr.io/atuinsh/atuin:latest
# start after the DB and Tailscale
depends_on:
- db
- tailscale
restart: always
command: server start
# Make config persistent
volumes:
- "./config:/config"
# Map the ports
ports:
- 8888:8888
environment:
# Listen an all interfaces.
ATUIN_HOST: "0.0.0.0"
# same port as above
ATUIN_PORT: 8888
# I don't keep registration open generally, but you probably need to set
# this to "true" once to set up a first account.
ATUIN_OPEN_REGISTRATION: "false"
# These env vars will be injected later. Make sure that `db` matches your
# database container's name!
ATUIN_DB_URI: postgres://${ATUIN_DB_USERNAME}:${ATUIN_DB_PASSWORD}@db/${ATUIN_DB_NAME}
RUST_LOG: info,atuin_server=debug
network_mode: service:tailscale
Given that we’re using Tailscale to access the server, I don’t worry about setting up TLS.
ATUIN_DB_URI
string. Now that the upstream
docs are fixed, it’s probably less likely you’d run into this issue if you
followed those.Postgres database#
Annotated container config:
db:
image: postgres:17
depends_on:
- tailscale
restart: unless-stopped
volumes:
# Don't remove permanent storage for index database files!
- "./database:/var/lib/postgresql/data/"
# If the WAL ever gets corrupt (e.g., due to abrupt container stops), fix it with:
# docker run -it -v ./database:/var/lib/postgresql/data/ postgres:17 /bin/bash
# su postgres && cd /var/lib/postgresql/data && pg_resetwal
environment:
POSTGRES_USER: ${ATUIN_DB_USERNAME}
POSTGRES_PASSWORD: ${ATUIN_DB_PASSWORD}
POSTGRES_DB: ${ATUIN_DB_NAME}
Tailscale#
We want our Tailscale container to be able to route to and from our Atuin server, while being able to independently authenticate to the network so that we don’t lose connectivity periodically. Tailscale docs are pretty good on how to set up containers , including OAuth configuration.
Annotated container config:
tailscale:
image: ghcr.io/tailscale/tailscale:latest
restart: always
# TS_AUTHKEY from the OAuth config
environment:
TS_AUTHKEY: ${TS_AUTHKEY}
TS_HOSTNAME: atuin
TS_STATE_DIR: /var/lib/tailscale
TS_EXTRA_ARGS: --advertise-tags=tag:container
volumes:
- "./tailscale:/var/lib/tailscale/"
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- net_admin
1Password#
I’ve known for a while that 1Password has a CLI integration, allowing secrets
access from the command line, in scripts etc. This seemed like a good spot to
try that. My approach was to reference the secrets as environment variables (via
mise
), have their values be paths into a 1Password vault, and start my
cluster by having op
inject the values at start-up time.
So, for example, to set the database password for Atuin, in mise.toml
I have:
[env]
ATUIN_DB_PASSWORD = "op://Personal/atuin/password"
All together now (hopefully)#
With the finished compose.yaml
for the cluster, and having set up the
respective secrets in 1Password, all that’s left to do is:
op run -- podman compose up -d
This should prompt for authentication with 1Password, and then bring up all the containers and have everything ready to go! As mentioned, you may need to allow for registration once, to set up one account that you’ll use. Or keep it on if you’re doing this for a team, friend group, polycule, etc. Given the server has to be reached over Tailscale, it won’t matter too much if you leave this on.
If you have issues, check the logs for clues. I’ve had the occasional issue with the DB WAL.
podman compose logs -n -t -f
You can also configure 1Password integration (and mise
populating the env) is
working as expected by printing the environment unmasked:
op run --no-masking -- printenv # might want to pipe into grep ATUIN
Atuin client#
Get Atuin (the client) from the official place
or use homebrew
/
another package manager of your choice. You’ll need a little configuration to
tell your client which is the right server to use.
Here’s my (annotated) configuration as an example, but only the sync_address
is really needed. For the rest, find what works for you, and make sure to look
at the Atuin docs
:
# Tailnet address and correct port; remember this is HTTP.
sync_address = "http://atuin.<your-tailnet>.ts.net:8888"
# Workspaces use per-git-repository history, rather than directory-only
workspaces = true
# Prefer to see the workspace history by default.
filter_mode_shell_up_key_binding = "workspace" # or global, host, directory, etc
# Run commands directly; tab to edit.
enter_accept = true
# Use Ctrl-0 .. Ctrl-9 instead of Alt-0 .. Alt-9 UI shortcuts; better on macOS.
ctrl_n_shortcuts = true
# Invert window?
invert = false
# Window style
style = "auto"
At this point you should be able to run atuin status
and see you’re green and
connecting to the server you’ve just configured!
It was probably this comic by Julia Evans that finally made me look. ↩︎