Skip to content

Standalone binary

If Docker isn’t an option — bare-metal homelab, traditional VPS without container support, or just personal preference — you can run Posthorn as a plain Go binary under systemd, supervisord, runit, or your init system of choice.

Pre-built binaries for common platforms are attached to each GitHub release:

Terminal window
curl -L -o posthorn.tar.gz \
https://github.com/craigmccaskill/posthorn/releases/download/v1.0.0/posthorn-linux-amd64.tar.gz
tar -xzf posthorn.tar.gz
sudo install -m 0755 posthorn /usr/local/bin/posthorn
Terminal window
go install github.com/craigmccaskill/posthorn/cmd/posthorn@latest

Requires Go 1.25+. The binary lands at $GOPATH/bin/posthorn (or $HOME/go/bin/posthorn by default).

Terminal window
git clone https://github.com/craigmccaskill/posthorn
cd posthorn
go build -o posthorn ./core/cmd/posthorn
Terminal window
posthorn --version
# posthorn v1.0.0

Four subcommands — serve, validate, version, help. The two you’ll use day-to-day:

Terminal window
posthorn validate --config /etc/posthorn/config.toml
posthorn serve --config /etc/posthorn/config.toml

See the CLI reference for the full surface.

validate parses the TOML, resolves ${env.VAR} placeholders, checks the schema, and compiles templates — without starting the listener. Exit 0 means good.

serve starts the HTTP listener on the address configured (default :8080).

See CLI reference for all flags.

Create /etc/systemd/system/posthorn.service:

[Unit]
Description=Posthorn email gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=posthorn
Group=posthorn
ExecStart=/usr/local/bin/posthorn serve --config /etc/posthorn/config.toml
EnvironmentFile=/etc/posthorn/posthorn.env
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadOnlyPaths=/etc/posthorn
RestrictAddressFamilies=AF_INET AF_INET6
LockPersonality=true
RestrictRealtime=true
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target

Create /etc/posthorn/posthorn.env:

Terminal window
POSTMARK_API_KEY=your-postmark-server-token

Restrict its permissions:

Terminal window
sudo chown root:posthorn /etc/posthorn/posthorn.env
sudo chmod 0640 /etc/posthorn/posthorn.env

Create the unprivileged user:

Terminal window
sudo useradd --system --no-create-home --shell /usr/sbin/nologin posthorn

Start and enable:

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now posthorn
sudo systemctl status posthorn

Logs:

Terminal window
journalctl -u posthorn -f

Posthorn handles SIGTERM and SIGINT for graceful shutdown:

  1. Stop accepting new connections.
  2. Drain in-flight requests up to each request’s 10-second timeout.
  3. Exit with code 0.

A second signal forces immediate exit. systemd’s default KillSignal=SIGTERM followed by TimeoutStopSec=90 (default 90 seconds) is more than enough for in-flight drain.

The binary listens on 0.0.0.0:8080 by default. Don’t expose this to the public internet. Bind to loopback or an internal network and reverse-proxy through Caddy, nginx, or your front door of choice. See Reverse proxy for example configs.

Same as the Docker variant — idle around 15-25 MB RSS, single-digit CPU under typical contact-form load. The binary itself is ~12 MB (statically linked Go binary, stripped).