Skip to main content

On-Prem Server Deployment

Deploy a fully functional on-prem ProjectAchilles install on a server you provide (reachable over SSH), on your own network, with working TLS — including options that need no public internet exposure.

This shares the same automation as the Public Server install; that page holds the architecture, backups, and day-2 reference. This page focuses on what's different on-prem: SSH-based install, certificate strategy on a private network, and making agents trust the server.

What's different on-prem

ConcernPublic dropletOn-prem
Provisioningdeploy-do.sh creates the hostYou provide the host; install via SSH
ReachabilityPublic IP, inbound 80/443Often internal-only, no inbound from internet
DNSPublic A recordInternal/split-horizon DNS to a private IP
TLSLet's Encrypt HTTP-01Internal CA, Let's Encrypt DNS-01, or your own cert
Agent trustAutomatic (public CA)May need the root CA distributed to agents

The server should be internet-connected to pull Docker images, OS packages, and the git-synced test library.

Choose a certificate strategy

Set TLS_MODE in deploy.config.env. All three produce a working HTTPS endpoint; they differ in what (if anything) clients must trust.

TLS_MODEUse whenPublic domain?Inbound 80/443?Client setup
internalIsolated/LAN, no public domainNoNoInstall exported root CA on browsers + agents
acme-dnsYou own a public domain (host may be internal)YesNo (DNS TXT)None — publicly trusted
byoYour org has its own PKI / a cert alreadyOptionalNoNone if clients already trust your CA
Recommendation

If you own a public domain, acme-dns is smoothest — publicly-trusted certs, zero client setup, no inbound exposure. Use internal for truly isolated networks, or byo to plug into an existing enterprise PKI.

Step 1: Configure

On your workstation, in the repo:

cp deploy.config.env.example deploy.config.env
$EDITOR deploy.config.env
ACHILLES_DOMAIN=achilles.corp.example.com
CLERK_PUBLISHABLE_KEY=pk_live_xxx
CLERK_SECRET_KEY=sk_live_xxx
ES_MODE=self
SSH_USER=root
SSH_HOST=10.0.5.20 # the server's reachable IP
REMOTE_DIR=/opt/projectachilles

Then add the TLS-mode fields:

# Option A — internal CA (nothing else needed)
TLS_MODE=internal

# Option B — Let's Encrypt DNS-01
TLS_MODE=acme-dns
ACME_EMAIL=admin@example.com
CADDY_DNS_PROVIDER=cloudflare
CADDY_DNS_MODULE=github.com/caddy-dns/cloudflare
CADDY_DNS_TOKEN=<scoped DNS API token>

# Option C — bring your own cert (place PEMs first)
TLS_MODE=byo
# cp fullchain.pem deploy/caddy/certs/cert.pem
# cp privkey.pem deploy/caddy/certs/key.pem

See github.com/caddy-dns for DNS provider module paths. The installer builds a custom Caddy image with the plugin automatically.

Step 2: Install over SSH

./scripts/deploy-remote.sh            # uses SSH_* from the config
# or: ./scripts/deploy-remote.sh user@host

This rsyncs the repo, copies your config (mode 600), and runs the installer remotely — installing Docker, generating secrets, rendering Caddy TLS, building, and starting the stack.

Make ACHILLES_DOMAIN resolve to the server on your network via internal DNS, a split-horizon zone, or per-client /etc/hosts:

10.0.5.20   achilles.corp.example.com

Step 3: Make agents (and browsers) trust the server

Only needed for TLS_MODE=internal (and byo when clients don't already trust the issuing CA). For acme-dns the cert is publicly trusted — skip this step.

Get the root CA

After an internal install, the script writes it to deploy/caddy/certs/root-ca.crt. Re-export anytime:

docker compose -f docker-compose.server.yml exec caddy \
cat /data/caddy/pki/authorities/local/root.crt > root-ca.crt

Browsers / OS

Import root-ca.crt into the OS/browser trust store (Linux: /usr/local/share/ca-certificates/ + update-ca-certificates; Windows: Trusted Root Certification Authorities; macOS: Keychain → System → Always Trust).

Agents

The Go agent supports a custom CA via its config (/opt/f0/achilles-agent.yaml on Linux/macOS, C:\F0\achilles-agent.yaml on Windows):

# 1. Copy root-ca.crt to the endpoint (e.g. /opt/f0/root-ca.crt).
# 2. Enroll, accepting the self-signed cert for this initial call only:
sudo ./achilles-agent --enroll <TOKEN> --server https://achilles.corp.example.com \
--install --allow-insecure

# 3. Point the agent at the CA and reload (no restart):
sudo sed -i 's#^skip_tls_verify:.*#skip_tls_verify: false#' /opt/f0/achilles-agent.yaml
echo 'ca_cert: /opt/f0/root-ca.crt' | sudo tee -a /opt/f0/achilles-agent.yaml
sudo ./achilles-agent --reload

After this the agent verifies the server against your internal root CA on every heartbeat. If endpoints already trust the internal CA at the OS level, skip ca_cert entirely — the agent uses the system trust store.

Verify

curl -sk https://achilles.corp.example.com/api/health   # -k tolerates internal CA

Then open https://achilles.corp.example.com/ from a client on the network.

Troubleshooting

SymptomCheck
Browser cert warning (internal/byo)Root CA not installed in the client trust store.
Agent: x509: certificate signed by unknown authorityca_cert path wrong, or the CA file wasn't copied to the endpoint.
acme-dns cert never issuesWrong CADDY_DNS_MODULE/provider or token lacks zone-edit permission: logs -f caddy.
Can't reach the hostInternal DNS resolves ACHILLES_DOMAIN? Firewall allows 443 from your subnet?
Full reference

The complete guide is in docs/deployment/ON_PREM_SERVER.md.