Since beginning to self-host my site and other projects on my bare metal Ubuntu server, there's been a fair share of Manual Configuration Management™ by yours truly. Since I'm a huge Linux noob, this is my design, and it's all very boilerplate. But still, it only takes a couple of websites or servers for the nginx and systemd config is starting to be annoying:
- I can't
scporrsyncover local (version controlled) copies automatically, due to how I've locked down my SSH user (it can't dosudo). So I end up SSH:ing into the Ubuntu box and manually pasting in the configs into the correct place. Feels good. - I haven't collected the configs in a central, version controlled location on my laptop, so it's hard getting an overview of what my project setup looks like.
Enter, Deptool. It's a Rust CLI program which let's me declare config files locally and push it to my server over SSH with deptool deploy. Sounds perfect! The documentation is amazing, so after a complete readthrough, I decided this was my weapon of choice.
Building
The project doesn't deliver prebuilt binaries (yet). Cool, I'm a Rust expert (I'm not), so I'll just build it on my machine:
$ git clone https://codeberg.org/ruuda/deptool && cd deptool
$ cargo build --release
$ ln -s ~/path/to/deptool/target/release/deptool /usr/local/bin/deptool
But the catch is (there always is when I do things outside of my comfort zone) is that I need to cross-compile it for my Ubuntu box (Linux x86_64) too, since the way Deptool works is by keeping an agent on my server during the time I push config updates to it. My laptop is a MacBook running macOS, so I can't use the same binary for both platforms.
(I could compile it on my Ubuntu box itself, but then I'd need the whole Rust toolchain, and since I'm cheap, the machine is not powerful at all, and everything will take ages…)
Okay, let's cross compile. The Rust project itself doesn't do this. Weird enough to me: I was surprised by this, since as with all things Rust, I would've guessed this was solved by some nice cargo command. But after some reading, apparently Rust gives me:
rustcwhich bundles LLVM, which generates code for every target Rust supports. Cool.rustup target add x86_64-unknown-linux-musl(or-gnu) gives me a prebuilt copy of Rust's standard library for that target. Cool.
What they deliberately don't give me:
- A C toolchain (compiler, linker, more…) for the target. Uncool.
This is apparently a scope thing for the Rust project, which I understand now: the project can't bundle and maintain toolchains for the 200+ targets it already supports, and cross-compiling C has always been annoying — Rust just inherited it.
After some searching around, people recommend these tools for cross-compiling:
- The
crosscrate. Uses Docker with a "real" Linux. Heavyweight, but robust. - The
cargo-zigbuildcrate. Uses the Zig language's C toolchain (!), and is quite lightweight (~100MB install).
So I'd use another language's tooling to make my language's tooling work. "Sweet, sign me up!". Let's go """lightweight""" with Zig.
First, we must tell Rust about our new Linux target:
$ rustup target add x86_64-unknown-linux-musl
- My Ubuntu box's CPU architecure is
x86_64(I ranuname -sm), so let's use that. unknown??- I used
muslsince that produces a static binary, unlikegnu, which produces a dynamic one. I've come to understand that the former is more portable but less performant.
Now do the Zig stuff:
# Install the language, put "Zig programmer" on your resume /s
$ brew install zig
$ cargo install --locked cargo-zigbuild
And build:
$ cargo zigbuild --release --target x86_64-unknown-linux-musl
Actually, we can run the build/linux-x86_64.sh script in the deptool project, since it does take care of some naming things for us (cat the script and read it). The binary ends up in target/deptool-bin/linux-x86_64:
$ tree target/deptool-bin/
target/deptool-bin/
└── linux-x86_64
└── deptool-1.0.0-1b1a976b5a
All this is to ensure the operator side's (my laptop) deptool binary is compatible with the host side's agent.
Finally, we must let the deptool program's install routine know where our cross-compiled Linux binary lives, which can be customised with DEPTOOL_BIN_DIR, but I'll just copy the binary to the default ~/.cache/deptool:
$ cp -R target/deptool-bin/ ~/.cache/deptool/
$ tree ~/.cache/deptool/
/Users/brookie/.cache/deptool/
└── linux-x86_64
└── deptool-1.0.0-1b1a976b5a
The first time we contact the server with deptool, maybe with the ping sub-command, it'll install the agent binary in /var/lib/deptool/bin.
Local setup
I'm unstoppable! Let's create the actual config files to use Deptool. This helps me collect all config in a single folder:
$ cd ~/code
$ mkdir johans-config
Let deptool create a "prod" cluster with init. I don't use clusters, but whatever:
$ deptool init prod
# Add my Ubuntu box called "my-server" henceforth
$ mkdir -p prod/my-server
The directory layout is like this:
- Clusters contain
- Hosts, which contain
- Apps, which contain
- the config files.
This is the final result after adding my existing web gym app as an "app":
tree .
.
└── prod
└── my-server
└── jym
├── manifest.json
├── nginx.conf
└── systemd
└── jym.service
The special file here is manifest.json:
{
"symlinks": {
"/etc/nginx/sites-available/jym": "nginx.conf"
},
"systemd": {
"units_enabled": ["jym.service"]
}
}
The docs describes this file well, but basically it makes deptool map local config files to the correct remote location by creating symlinks, as well as automatically restarting systemd services with the specified config files on deploy.
Cool! Now I can just add more apps to the my-server directory, and deptool deploy will use some smart diffing and deploy the changes?
Deploying with deptool deploy
Let's follow the tutorial and get cracking.
Prerequisites says I must be able to do this:
$ ssh my-server 'sudo cat /etc/hostname'
my-server
I got:
sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is required
Also confirmed deptool ping failed with:
Broken pipe (os error 32)
Uh-oh. So deptool deploy needs root access via my intentionally locked down default SSH user. Not super psyched about that. Let's create a new deptool SSH user.
The deptool user
Begin with a new SSH key on my laptop:
$ ssh-keygen -t ed25519 -f ~/.ssh/id_deptool -C "deptool@my-server"
Add a new Ubuntu user account on my-server:
$ ssh my-server
$ sudo adduser --disabled-password --gecos "" deptool
# Public SSH key
$ sudo install -d -m 700 -o deptool -g deptool /home/deptool/.ssh
$ echo 'restrict PASTE_CONTENTS_OF_id_deptool.pub_HERE' \
| sudo tee /home/deptool/.ssh/authorized_keys
$ sudo chown deptool:deptool /home/deptool/.ssh/authorized_keys
$ sudo chmod 600 /home/deptool/.ssh/authorized_keys
restrict in authorized_keys apparently disables pty allocation, port/agent/X11 forwarding, and tunneling — none of which Deptool uses.
Let's scope what this user can actually use sudo on in sudoers:
Cmnd_Alias DEPTOOL = \
/usr/bin/mkdir -p /var/lib/deptool/bin /var/lib/deptool/apps /var/lib/deptool/store, \
/usr/bin/chmod 0700 /var/lib/deptool/store, \
/usr/bin/dd status=none of=/var/lib/deptool/bin/deptool-*, \
/usr/bin/chmod +x /var/lib/deptool/bin/deptool-*, \
/usr/bin/sha256sum /var/lib/deptool/bin/deptool-*, \
/var/lib/deptool/bin/deptool-* agent /var/lib/deptool/store
deptool ALL=(root) NOPASSWD: DEPTOOL
Those commands are the exact agent install commands required by deptool.
Patching deptool 😨
Ok, all done! But how do we make deptool use this user… I've got this in my .ssh/config:
Host my-server
HostName server-ip
User my-user
Port server-port
IdentityFile ~/.ssh/my-key
There's a problem. deptool welds three things together:
- the
/etc/hostnameof the server:my-server, - the name of the cluster in the local config directory:
my-server, - the
Hostin the SSH config:my-server.
All this will make deptool "claim" the my-server host in the SSH config, and thus use my locked-down my-user account (I won't make any changes to neither that account nor the my-server host, since I use it in scripts elsewhere on my laptop).
I thought I could rename the strings to something else (my-server-deploy), but I can fix 2), but won't rename 1) and 3). So we must use my-server as the canonical host naming.
But how can we make it use our new deptool user account and SSH key?
Well, we just built the tool from source, soooo… let's hack the source! After some exploring with Claude, this is what I came up with:
diff --git a/src/setup.rs b/src/setup.rs
index ed4efad..7df84c4 100644
--- a/src/setup.rs
+++ b/src/setup.rs
@@ -30,6 +30,13 @@ pub const BIN_DIR: &str = "/var/lib/deptool/bin";
/// instead of waiting the OS TCP default (~2 min) -- and the idle
/// keepalive, so a host that hangs mid-session releases the deploy
/// instead of blocking it indefinitely.
+///
+/// When `DEPTOOL_SSH_USER` is set, deptool logs in as that user instead
+/// of the one `ssh` would pick from `~/.ssh/config`. This lets a deploy
+/// use a dedicated, locked-down account without claiming the bare
+/// hostname in `ssh_config`, which must keep matching `/etc/hostname`
+/// for the identity check. Bind the account's key with a matching
+/// `Match user` block in `ssh_config`.
pub fn ssh_command() -> Command {
let connect_timeout_seconds = 10;
let server_alive_interval_seconds = 10;
@@ -43,6 +50,9 @@ pub fn ssh_command() -> Command {
"-o",
&format!("ServerAliveCountMax={server_alive_count_max}"),
]);
+ if let Some(user) = std::env::var_os("DEPTOOL_SSH_USER").filter(|s| !s.is_empty()) {
+ cmd.arg("-l").arg(user);
+ }
cmd
}
So we add a DEPTOOL_SSH_USER environment var to pass it as -l to the SSH command inside of deptool, which overrides my User entry in ~/.ssh/config. Awesome!
And a final tweak to ~/.ssh/config before any other Host * section, since order matters:
Match user deptool
IdentityFile ~/.ssh/<the-deptool-key>
# This will force SSH to not use *all* identities, but only the one listed in IdentityFile:
IdentitiesOnly yes
# No need to save this key around to the SSH agent, we use only our IdentityFile:
AddKeysToAgent no
This will force deptool to use the correct key we added earlier when communicating over SSH with my-server.
What we've achieved now is isolation, where deptool has:
- a dedicated user + key,
- no interactive shell,
- scoped
sudoto exact commands.
Naturally, I need to redo everything in the Building section above, since I touched the Rust source…
Let's try:
$ export DEPTOOL_SSH_USER=deptool
$ deptool ping
my-server: 30.2 ms | 31.5 ms | 34.6 ms (min/p50/p95 rtt, n=23
Yay! Success! Fire! It freaking works:
$ deptool deploy
my-server
update jym
~ nginx.conf
restart unit jym.service
Auto-rollback if deploy fails.
Apply to 1 host in cluster 'prod'? [y/N/d] y
my-server:
● jym.service - jym sync server
Loaded: loaded (/etc/systemd/system/jym.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-08-04 19:42:02 UTC; 309ms ago
Main PID: 508382 (node-MainThread)
Aug 04 19:42:02 my-server systemd[1]: Started jym.service - jym sync server.
Aug 04 19:42:02 my-server node[508382]: Using db at path: /opt/jym/server/db.sqlite
Aug 04 19:42:02 my-server node[508382]: Server listening on 4000
my-server: done
Changes deployed successfully to 1 host in 1.43s.
Tears of joy. Checking the symlinks confirms it.
Outro
I imagine doing sweeping changes across clusters/servers/apps and deploying them in ~2 seconds is really cool with deptool. For me, I learned a bunch of Linux and Rust, and have a way of managing config for my smol side projects.
I especially would like to point out the top notch quality of the Deptool docs. I'm super thankful for them, since the project author created the tool for their own use foremostly. They could've just said "works on my machine, kthxbye". Instead, the documentation is succinct but covering, and relays the ideas behind the tool very well.