Ork: SSH Server Automation in Go

I built Ork — a Go library and CLI for SSH-based server automation. Think Ansible, but written in Go: no YAML, no Jinja2, no Python environment. Just Go code that connects to your servers and manages them.

Ork: SSH Server Automation in Go

Ork: Server Automation in Go

For the last 100+ days I've been using Go to manage my servers instead of Ansible.

Most Go developers use Ansible for server automation. It's the reasonable choice — mature, widely used, and well-documented. But it's also Python-based, YAML-heavy, and lives in a completely separate mental universe from your application code. When something breaks, you debug it in YAML, not Go. And if you develop on Windows, you can't run Ansible at all without WSL or a Linux VM — Ansible has no native Windows support for the control machine.

So I built Ork. This is what those 109 days taught me.

The Standard Solution: Ansible

The industry-standard answer to this problem is Ansible. Ansible connects to your servers over SSH and runs tasks you define in files called playbooks. Playbooks are written in YAML. YAML stands for "YAML Ain't Markup Language," which is the kind of recursive acronym that gets invented at 2am and stays forever.

Here's a simple Ansible playbook that installs nginx on a server:

# inventory.ini
server.example.com

# playbook.yml
- hosts: all
  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present
      become: yes

That's actually quite readable. Ansible gets credit for that. The problem shows up when you need logic. Conditionals. Loops. Error handling that does different things in different cases. Then you need Jinja2 — a templating language that lives inside your YAML and allows you to write expressions like this:

{{ hostvars[inventory_hostname]['ansible_default_ipv4']['address'] }}

That's a real Ansible expression. It gets the default IPv4 address of the current host. The Ork equivalent:

ip := node.GetNodeConfig().SSHHost

It's YAML-inside-Jinja2, both of which are sensitive to spacing, both of which have their own rules about quotes, both of which will fail silently or with a misleading error if you get something slightly wrong. If you write Go, you are accustomed to the compiler catching your mistakes before your code runs. YAML and Jinja2 will not extend you this courtesy.

What If You Just... Used Go?

This is the question I kept asking myself. I write Go. My application is Go. My tests are Go. My build pipeline produces Go binaries. Why is my server automation YAML, Python, and Jinja2?

And then there's the Windows problem. Ansible does not run natively on Windows. The control machine — the machine you run Ansible from — must be Linux or macOS. If you develop on Windows, the official answer is to install WSL (the Windows Subsystem for Linux), which gives you a Linux environment inside Windows, from which you can run Ansible, which then connects over SSH to your Linux servers. That's three layers of operating system to run what is essentially a script that SSHes into a machine and runs a command.

The answer to "why YAML, Python, and Jinja2?" is: "because that's what everyone uses." Which, to be fair, is a real answer. Mature ecosystem, lots of documentation, your colleagues might already know it. But "install a whole Linux subsystem just to start" is not a great onboarding story.

So I built Ork. The same nginx install, in Go:

node := ork.NewNodeForHost("server.example.com")
result := node.Run(apt.NewAptInstall().SetArg(apt.ArgPackages, "nginx")).FirstResult()
if result.Error != nil {
    fmt.Printf("Installation failed: %v\n", result.Error)
} else {
    fmt.Printf("Installation succeeded: %s\n", result.Message)
}

No YAML. No Jinja2. No Python. Just Go.

What Ork Does

Ork gives you the same conceptual model as Ansible — Nodes (servers), Groups, Inventory — but everything is Go. No YAML. No Jinja2. No Python environment. You write a Go program. It connects to your servers over SSH and runs.

The basic unit is a Node:

node := ork.NewNodeForHost("server.example.com").
    SetUser("deploy").
    SetPort("22")

results := node.RunCommand("uptime")

More useful is the concept of a Skill. A Skill is a pre-built, idempotent operation — roughly equivalent to an Ansible module. There are built-in Skills for package management, user creation, database setup, firewall rules, fail2ban, SSH hardening, and more. You run them against a Node, a Group, or an entire Inventory:

// One server
results := node.Run(skills.NewSSHHarden())

// All web servers at once
results := webGroup.Run(skills.NewAptUpgrade())

// Everything in your inventory
results := inv.Run(skills.NewPing())

When the built-in Skills aren't enough, you write a Playbook. A Playbook is a Go struct with a Run() method. Inside it, you can use any Go code: external API calls, database queries, conditional logic, goroutines for parallelism. The full language is available.

The Skills That Ship With Ork

Out of the box, Ork includes Skills for the tasks that come up every time you set up a server. Not theoretical tasks. Actual ones:

System basics

  • Ping (verify SSH connectivity)
  • Reboot
  • Apt update / upgrade / status

Users

  • Create user (with sudo access)
  • Delete user
  • List users
  • User status

Security

  • SSH hardening (disable root login, restrict ciphers, etc.)
  • Kernel hardening
  • AIDE intrusion detection
  • Auditd installation
  • Change SSH port

Firewall

  • UFW install and status
  • UFW rules (MariaDB, and more)

Fail2ban

  • Install and status

Swap

  • Create (specify size in GB), delete, status

MariaDB

  • Install, secure installation
  • Create databases, create users
  • List databases and users
  • Backup (plain and encrypted)
  • Security audit
  • Change port, enable SSL, enable encryption at rest

Every single one of these is idempotent. If the thing is already done, the Skill does nothing and reports "no change." If it isn't done, the Skill does it and reports "changed." You can run your automation every day as a sanity check. Nothing happens unless something needs to happen.

What You Get from Go

Because Ork is standard Go, your IDE works the way it always does. Autocomplete on every method. Type errors caught before you run anything. Inline documentation. Jump to definition. Rename refactoring that works across your entire codebase — including your automation code. Ansible playbooks are YAML files: your IDE can syntax-check them, but it can't tell you that a variable doesn't exist, that a module argument is wrong, or that a condition will never be true. You find those things at runtime, on a production server.

You also get the full language. Real if statements. for loops. switch on anything. Error handling that is explicit and composable. Goroutines for running tasks in parallel. And any Go library or API call in the ecosystem — if you can import it, you can use it in your automation. Need to query your database before deciding which servers to update? Call your existing code. Need to notify Slack when a deployment finishes? Import the SDK and call it directly. With Ansible, every capability outside the module catalog requires writing a custom module or shelling out.

Compile-time type checking catches configuration errors before they reach production. Your automation code can live in the same repository as your application, go through the same code review, and be tested with the same test framework.

You also get a single static binary that runs anywhere Go runs — Linux, macOS, and Windows. Compile once, copy to your CI machine, run it. No Python environment, no virtualenv, no pip install, no WSL workaround because Ansible won't start. The deployment story for your deployment tool is just: copy the binary.

And because it's a library, you can embed Ork in an existing Go application. An admin panel that can also provision servers. A deployment tool that knows about your database schema. Things that would require significant glue code between Ansible and your application become first-class code.

Secrets Management

Every server automation tool eventually confronts the same problem: secrets. Database passwords, SSH credentials, API keys — your playbooks need them, but they can't be hardcoded or left in plain text files. Ansible's answer is Ansible Vault, an encryption layer built into the tooling. Ork has the same answer: a built-in vault, using AES-256-GCM encryption with Argon2id key derivation. You open it with a password, read from it in your playbooks, and close it when you're done:

v, err := vault.Open("secrets.vault", "my-password")
if err != nil {
    log.Fatal(err)
}
defer v.Close()

dbPassword, _ := v.Get("DB_PASSWORD")
apiKey, _ := v.Get("API_KEY")

There's a CLI for managing vault contents without writing code:

ork vault init secrets.vault
ork vault set secrets.vault DB_PASSWORD secret123
ork vault get secrets.vault DB_PASSWORD

And for those who prefer not to work in the terminal at all, there's a local web UI:

ork vault ui secrets.vault

That starts a server at 127.0.0.1:38080 with a browser interface for logging in, viewing, adding, updating, and removing secrets. Ansible Vault has no equivalent — it's CLI only. The web UI is a meaningful step toward making secrets management accessible to the whole team, not just whoever is comfortable at the command line.

Passwords are never passed as command-line arguments — they're read from stdin, so they don't appear in your shell history or process list. The vault file is a single base64-encoded string wrapped at 80 characters, safe to commit alongside your code and readable in git diffs.

Dry-Run and Idempotency

Every Skill implements idempotency. If the thing is already done — the user already exists, the package is already installed, the port is already changed — the Skill reports no change and does nothing. You can run your automation daily without side effects.

You can also check before you run. node.Check(skill) returns what would change without actually changing it:

result := node.Check(skills.NewAptUpgrade()).FirstResult()
if result.Changed {
    fmt.Println("Updates available, running upgrade...")
    node.Run(skills.NewAptUpgrade())
}

An Honest Comparison

Ork is not a replacement for Ansible in the general case. Ansible has over 3,000 modules, a Galaxy ecosystem, network device management, and years of battle-tested deployments behind it. If you need those things, use Ansible.

Ork is for Go developers managing Linux servers — especially those working on Windows, where Ansible's requirement for a Linux control machine is an immediate barrier. Ork compiles and runs natively on Windows, macOS, and Linux. The bet it makes is: if your application is Go, your infrastructure automation should also be Go. Whether that trade is worth it depends on how much you value consistency over ecosystem size.

For small teams running a handful of VPS instances, Ork's approach is compelling. The automation is readable by any Go developer on the team, testable with standard Go tools, and embeddable in the application itself. The cost is a smaller library of built-in Skills compared to Ansible's module catalog.

Getting Started

CLI:

go install github.com/dracory/ork/cmd/ork@latest

Library:

go get github.com/dracory/ork

Full documentation and examples are at github.com/dracory/ork.

Previous
Deno Deploy Killed My Start Page

Keep Reading

Explore more articles and insights

Deno Deploy Killed My Start Page

Deno Deploy Killed My Start Page

Read Article
Back to Golang: Why I Switched From Static to Dynamic Again

Back to Golang: Why I Switched From Static to Dynamic Again

Read Article
Associations and Eager Loading in Go — Without GORM

Associations and Eager Loading in Go — Without GORM

Read Article
View All Blog Posts