What is freeholdy
freeholdy is a single-VPS orchestrator for your Docker apps. It puts
every project behind nginx + Let's Encrypt and serves each one at its own
{name}.your_domain.com subdomain — with HTTPS wired up automatically.
You push code (a folder, a git URL, or a pre-packaged plugin); freeholdy auto-detects
whether it's a single-container Dockerfile project or a multi-container
docker-compose.yml stack (compose wins when both are present), builds it,
runs it, and wires up the reverse proxy and SSL certificate. No per-project nginx or
certbot configuration to write.
There are three ways to drive it:
- CLI —
fhcli, a small command-line client (used in the examples below). - Web UI — a control panel at
ui.<your-domain>covering the same operations in the browser. - REST API — everything above is a thin client over the API at
api.<your-domain>, authenticated with a bearer token.
dockerfile project has a single container row; a compose
project lists every service, with the internal ones marked
unproxied (no subdomain, no port of their own).
Installation
Prerequisites
- A fresh Ubuntu VPS with root access. The installer handles all packages itself.
- A base domain with a wildcard DNS record (
*.your_domain.com) pointing at the server — projects are served at{name}.your_domain.comand the API atapi.your_domain.com.
One-command bootstrap
bash <(curl -fsSL https://raw.githubusercontent.com/aafanasev-dev/freeholdy/main/install.sh)
On a server that already runs other apps, clone the repo and run
sudo bash install.sh instead. The installer auto-detects one of two modes
and asks you to confirm before touching anything:
- FRESH — docker and/or nginx are missing → it installs what's missing. For a dedicated or empty VPS.
- COEXIST — both are already present → it never installs, restarts, or upgrades docker or nginx, and aborts early if your existing nginx config is broken. Safe next to other apps.
The installer then:
- Prompts for a service user (default
freeholdy), your base domain, and a Let's Encrypt email. - Sets up the service user with docker access and passwordless nginx/certbot sudo.
- Runs
configure.shto build the Python venv (one venv serves the server and the CLI), writes.env, and picks a free local API port. - Adds the
api.<domain>nginx vhost, obtains its SSL certificate, and installs a nightly renewal cron. - Installs and starts the
freeholdysystemd service. - Prints your first API token — shown once; save it.
Flags: -u USER (service user), -y (assume yes),
-r (redo every step). Progress is tracked in install.log, so
re-running is idempotent — it skips finished steps and can, for example, re-enable SSL
after DNS propagates.
Set up the CLI
The CLI shares the project's single venv — there is no separate one under
cli/. On your workstation, from a clone of the repo:
bash configure.sh # builds ./venv (server + CLI deps)
cp cli/.env.example cli/.env # set TOKEN and BASE_DOMAIN
See The Python environment for what
configure.sh does and its flags.
Check it works with ./cli/fhcli.py health — no
source venv/bin/activate needed, because fhcli.py
re-execs itself under ./venv/bin/python. On the server the
installer has already done this and linked fhcli into
/usr/local/bin, so you can just run fhcli health.
The same token logs you into the web UI at ui.<your-domain> — it is
stored in the browser and sent as a bearer token on every request.
/health before being stored.
Updating freeholdy
install.sh is a bootstrap, not an upgrader — it records finished steps in
install.log and skips them on a re-run, so running it again never pulls new
code. update.sh is the supported way to move an installed server to a newer
revision.
sudo bash update.sh
Picking a version
It fetches from origin and lists what you can move to: the
main branch plus every release tag, each shown with the version it declares
in version.json and its latest commit. The revision you are currently on is
marked (current).
1) main v0.11.4 0bcc32c Updating help plugin (current)
2) v0.11.0 v0.11.0 2eb9030 Env variables handling
? Which version? [1]:
Use -l to see that list and exit without changing anything, or
-v REF to name a revision up front and skip the prompt. After you choose, it
prints exactly what it is about to do and asks once more —
nothing on the server changes until you confirm.
What it does
-
Removes the
webuiandfreeholdy-helpprojects, if they are installed. They are rebuilt in step 7 — that is how the new code reaches them. - Stops the
freeholdyservice. - Hard-resets the checkout to the revision you chose.
- Runs
configure.shto bring the Python environment up to date. - Backs up
data/freeholdy.db, then runsmigrate_db.sh. - Starts the service again and waits for
/healthto answer. - Re-adds the plugins it removed, waiting for each container build to finish.
| Flag | What it does |
|---|---|
-u USER | Service user. Defaults to the one read out of the systemd unit. |
-v REF | Revision to update to — main or a tag name. Skips the prompt. |
-y | Assume yes to every confirmation. Implies -v main when -v is absent. |
-l | List the available versions and exit. Changes nothing. |
What survives
Everything covered by .gitignore is kept — the reset never passes
clean -x. That means .env, data/ (your database),
projects/, dockerfiles/, compose/,
nginx_configs/, cli/.env and venv/ all stay as
they are. Your deployed projects and their containers are never touched
— only freeholdy itself is updated.
Existing API tokens keep working, so your web UI login link stays valid.
update.sh mints a temporary token for its own API calls and revokes it when
it exits, leaving no new credential behind.
git reset --hard and git clean -fd. If you have patched
anything inside the repo, commit it to your own branch first.
If it fails
Nothing is changed before you confirm, and any failure up to the moment the service stops leaves the server exactly as it was. Past that point the script prints the path of the database backup it took, the revision you were on, and the commands to put both back:
sudo -u freeholdy git -C /home/freeholdy/freeholdy reset --hard <previous-rev>
cp <backup> /home/freeholdy/freeholdy/data/freeholdy.db
sudo systemctl start freeholdy
The Python environment
configure.sh owns the project's single virtualenv at
venv/. One environment serves both the API server and
fhcli — the CLI re-execs itself under venv/bin/python, so it
needs no source venv/bin/activate.
bash configure.sh
Re-running it is cheap: it stores a hash of requirements.txt
inside the venv and skips the install while that hash still matches. That is why
update.sh can call it every time — the work only happens when the
dependencies actually changed. It also rebuilds the venv from scratch if the Python
interpreter it was built with is no longer the one on PATH.
It runs as whoever invokes it — only the python3.X-venv package fallback
needs sudo. On a server the installer already ran it as the service user.
| Flag | What it does |
|---|---|
-d DIR | Project directory holding requirements.txt. Defaults to the script's own directory. |
-f | Reinstall the dependencies even when the stored hash still matches. |
-h | Print the script's usage and exit. |
Deploying apps
There is no separate "create project" step — the first deploy creates the project, and re-running the same deploy redeploys it. Every deploy streams its build log live to your terminal.
From a folder
fhcli deploy myapp ./myapp
Uploads the folder, auto-detects the manifest — a Dockerfile (it must
EXPOSE a port) or a docker-compose.yml — builds it, runs it,
and wires up nginx + SSL at myapp.your_domain.com. Compose stacks get one
subdomain per exposed service ({service}.myapp.your_domain.com). Re-run
the command to redeploy.
Add --env .env to store the project's
environment variables before the container first starts.
From git
fhcli deploy mysite https://github.com/owner/repo.git
fhcli deploy mysite git@github.com:owner/repo.git --branch dev
Clones the repo on the server and runs the exact same detect → build → run → nginx pipeline. Re-run to redeploy the latest commit.
For private repos, run fhcli get-git-key — it prints the
server's SSH public key (generated on first use); add it as a deploy key on the repo,
then deploy over the git@… URL.
From plugins
fhcli plugins # list available plugins
fhcli plugin-add nextcloud mycloud # install one as project "mycloud"
Plugins are pre-packaged apps that deploy through the same pipeline. Some are
interactive: the install prompts you right in the terminal (e.g. to
choose an admin account) before the build starts. If your terminal disconnects
mid-install, re-running plugin-add resumes it.
install.sh or is interactive; the panel is the
plugin's own ABOUT.md.
Plugin catalog
☁️ nextcloud interactive
Nextcloud file sync & share at nextcloud.<domain> (app, postgres, redis, cron). The install prompts for the admin account.
📮 mailserver interactive
Full e-mail server (docker-mailserver) — SMTP, IMAP, and DKIM at mail.<domain>. Choose addons and the first mailbox during install.
✉️ snappymail interactive
SnappyMail webmail — a web UI for your mail server at mailui.<domain>. Pairs with the mailserver plugin.
🎥 jitsi-meet interactive · UDP 10000
Jitsi Meet video conferencing at meet.<domain>. Choose the moderator account during install; UDP 10000 must be reachable from the internet.
🖼️ imgstore
Token-gated image storage — upload via a web UI, share via public links with custom names or auto sha256 URLs.
📂 sftp-go interactive
Personal SFTPGo file server — SFTP, WebDAV & WebClient over your own folder.
🛡️ amneziavpn interactive · UDP port
AmneziaWG (DPI-resistant WireGuard) VPN server on a raw UDP port — no subdomain, no SSL. Pick the port, client DNS, and first client during install; your firewall must allow the UDP port in.
🔑 outline interactive · 2 ports
Outline Server (Shadowbox) — self-hosted Shadowsocks VPN managed from the Outline Manager desktop app. Pick the API and access-key ports during install; your firewall must allow both in.
📖 freeholdy-help
This guide — a static page that also doubles as a smoke test for a fresh install.
Versions & rollback
Every deploy creates a new version, and the previous ones are kept so you can go back. The new version is built and verified before traffic moves to it, so a failed build never takes the live site down.
fhcli versions myapp # list versions: active / inactive / archived
fhcli rollback myapp 2 # make version 2 live again
fhcli set-backup-limit myapp 3 # keep at most 3 archived versions
How a deploy switches over
Single-container projects are true blue/green. Version N is
built as its own image and started in its own container on its own local port, next to the
one still serving. Only once the build succeeds and the new container is confirmed
running does nginx get re-pointed at the new port — a one-line change and a reload. If the
build fails, nothing was touched.
Compose stacks are build-first with a brief switch. Running two copies of a
stack side by side isn't possible in general — services bind host ports themselves, some use
host networking, and named volumes are shared. So freeholdy builds and pulls
while the old stack keeps serving, tags every resulting image as version
N, and only then does a short down + up. The images
are already on disk, so the gap is seconds. A failed build or pull never reaches that point.
What the three states mean
- active — the running version nginx points at.
- inactive — the version you just replaced, kept as a stopped container for an instant rollback. Single-container projects only — bringing a compose stack down removes its containers, so compose versions go straight to archived.
- archived — older versions whose images (and, for compose, a snapshot of the project files) are retained, but whose containers are gone and whose ports are freed. Capped by the backup limit — default 5, oldest pruned first after a successful deploy.
What a rollback restores
A rollback puts back code and images. For a single-container project the container is recreated from that version's image rather than just restarted — which is what lets a rollback pick up the project's current environment variables. For a compose stack, freeholdy restores the snapshot of the project files, re-wires nginx (custom domains and plugin subdomains are preserved), and brings the stack up on the retained version tags.
Rollbacks stream their log over the same channel as a deploy, so
fhcli rollback shows you exactly what is happening.
Backups
A version protects you from a bad deploy; a backup protects you from a
lost server. One backup is a single self-contained .tar holding that
version's image(s), the contents of the project's volumes, its stored
.env files and its project tree — take it off the box and bring the
project back later, even on a different machine.
fhcli backup myapp # archive it now (the log streams live)
fhcli backups myapp # list the archives
fhcli backup-download myapp 4 -o out.tar # take one off the server
fhcli backup-upload myapp out.tar # bring it back → imported as v7
fhcli rollback myapp 7 --restore-data # activate it, data and all
Restoring is a rollback
Uploading an archive overwrites nothing. It loads the archive's images under the
project's next version number, and that version shows up in the
versions list as archived — so
activating a backup is the same rollback you already use, and there is only ever one
answer to "which build is live". A plain rollback swaps code and images and leaves your
data alone; add --restore-data to put that archive's volume contents and
environment back at the same time.
Automatic backups
Two independent triggers per project, set with fhcli backup-config or in the
panel above: a cron schedule (armed when you save it; a window missed
while the server was down is caught up once on the next boot) and
after every successful deploy. Retention is a count — how many archives
to keep here, and how many at the destination. Destinations themselves
(rsync over ssh, or FTP/FTPS) are declared as BACKUP_TARGET_*
blocks in the server's own .env, so their credentials never
reach the database, the API or this page — only the destination's name does. The
freeholdy database is a backup scope of its own, with the same commands
(fhcli db-backup).
Schedules, destinations and the database scope are admin only. A guest token can take, download and delete manual backups of the projects it is scoped to, which is enough for a CI job to snapshot before it ships.
Environment variables
freeholdy stores a .env file per project on the server and injects it every
time a container is created. Nothing is baked into the image, and the values live outside
the project directory — so a git redeploy or a rollback can't wipe them.
Scopes
Each project has a project-level file. For a single-container project that file is the container's environment. For a compose stack it is shared by every service — and each service may additionally have its own file, whose values win over the shared ones.
fhcli env-set myapp .env # the project-level file
fhcli env-set mystack db.env -s db # one service (wins over the shared file)
fhcli env-get myapp # print it
fhcli env-get myapp > .env # …or save it
fhcli env-clear myapp # delete it
The format is ordinary dotenv — comments, blank lines, an export prefix, and
single- or double-quoted values are all accepted, and the file is stored exactly as you
wrote it. A malformed key or a value split across lines is rejected with the line
number, rather than quietly producing a container with a broken environment.
Applying changes
.env does nothing until the container is recreated.
fhcli restart myapp
restart recreates the container(s) from the images they are already running:
no rebuild, no new version, just a fresh container with the current
environment. For a compose project only the services whose environment actually changed are
recreated. Until you do it, freeholdy reports the stored file as not applied —
the CLI prints a hint and the web UI shows a "restart to apply" banner.
fhcli restart
does — recreate the container, no rebuild.
The first start
Because setting variables never starts anything, a project that is deployed before
its .env exists boots without one. To have the values in place for the very
first container, send them along with the deploy:
fhcli deploy myapp ./myapp --env .env
fhcli deploy mysite https://github.com/owner/repo.git --env .env
cat .env | fhcli deploy myapp ./myapp --env -
The web UI's deploy form has the same box. Leaving it blank on a redeploy
keeps whatever is already stored — it never silently wipes a project's environment.
Clearing is always explicit (fhcli env-clear).
freeholdy never hands your values back out in listings: the project list carries only a count, and reading a file is a deliberate, separate request.
Logs & shell
Container logs
fhcli logs myapp # last 200 lines
fhcli logs myapp -n 50
fhcli logs mystack -s api # one compose service
fhcli logs myapp | grep -i error
This is what your app printed — the container's own stdout and stderr. For
a compose project you get the whole stack interleaved and prefixed with the service name;
-s narrows it to one service. It's a snapshot rather than a live follow, and it
goes to stdout, so it pipes and redirects cleanly.
Not to be confused with fhcli status myapp, which shows the log of the last
freeholdy operation — the build, the run, the stop. When a deploy fails,
read status; when the app misbehaves after starting, read logs.
Interactive shell
fhcli exec myapp # a shell in the container
fhcli exec myapp "python manage.py shell"
fhcli exec mystack -s api # one compose service
A real terminal over a WebSocket — a full TTY, so editors, colours, and interactive prompts all work as they would over SSH. The web UI offers the same shell in the browser.
exec button, so you land in that service's container.
API
The CLI, the web UI, and the fhdeploy skill are all thin clients over one
REST API at https://api.<your-domain>. Anything they can do is
scriptable — from CI, a cron job, or your own dashboard.
- Auth — every endpoint takes
Authorization: Bearer <token>, except/health,/version, and/docs. - Interactive reference — the OpenAPI explorer is live at
https://api.<your-domain>/docs, with every request and response schema. - Long jobs return immediately — builds, deploys, rollbacks, and plugin installs respond with a
ws_path; the log streams over that WebSocket.
TOKEN=your_token_here
BASE=https://api.your_domain.com
# Deploy a folder — auto-creates the project, detects the Dockerfile or
# docker-compose.yml, builds, runs, and wires up nginx + SSL.
curl -X POST "$BASE/projects/myapp/upload" \
-H "Authorization: Bearer $TOKEN" \
-F "files=@./Dockerfile;filename=Dockerfile" \
-F "files=@./app.py;filename=app.py"
# ...or deploy straight from git (idempotent: same name = redeploy).
curl -X POST "$BASE/git/add" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"mysite","git_url":"https://github.com/owner/repo.git"}'
curl "$BASE/projects/myapp/status" -H "Authorization: Bearer $TOKEN"
/upload above is the simple path. fhcli and the
web UI use the chunked pair instead — upload/chunk +
upload/complete, 1 MiB pieces of a staged zip — which is what large folders
need to stay under nginx's request body limit.
Endpoints
| Method | Path | What it does |
|---|---|---|
| System | ||
| GET | /health | Liveness check — no auth. |
| GET | /version | Server version — no auth. |
| Projects & deploys | ||
| GET | /projects/ | Every project with its container / service status. |
| DELETE | /projects/{name} | Full teardown — containers, images, versions, nginx config, files, DB row. |
| POST | /projects/{name}/upload | Multipart deploy. Auto-creates the project on first use. |
| POST | /projects/{name}/upload/chunk | One raw piece of a staged zip (?upload_id=&offset=). |
| POST | /projects/{name}/upload/complete | Unzip → detect manifest → provision → build + run. Optional env (dotenv text) is stored first, so it reaches the first start. |
| DELETE | /projects/{name}/upload/{id} | Abort a chunked upload and discard its staged data. |
| POST | /git/add | {"name","git_url","branch"?,"env"?} — clone + deploy. Same name redeploys. |
| GET | /git/key | The server's GitHub SSH public key, for private repos (created on first call). |
| Lifecycle — single container | ||
| GET | /projects/{name}/status | Container state plus the last job's log. |
| POST | /projects/{name}/stop | Stop the running container. |
| POST | /projects/{name}/restart | Recreate the container(s) from their current images — no rebuild. Works for both modes; this is what applies edited environment variables. |
| POST | /projects/{name}/abort | Abort the docker job currently in flight. |
| POST | /projects/{name}/ssl | Re-run certbot for the project's domains. |
| POST | /projects/{name}/domain | {"custom_domain":"app.acme.com"} — send null to revert to the subdomain. |
| Lifecycle — compose stacks | ||
| GET | /projects/{name}/compose/status | Stack state plus the last compose job's log. |
| POST | /projects/{name}/compose/down | Tear the stack down. |
| POST | /projects/{name}/compose/abort | Abort the compose job currently in flight. |
| POST | /projects/{name}/services/{svc}/domain | Custom domain for one service of the stack. |
| Versions | ||
| GET | /projects/{name}/versions | Active / inactive / archived versions with counts. |
| PUT | /projects/{name}/backup-limit | {"limit":3} — set the cap and prune right away. |
| POST | /projects/{name}/rollback | {"version":2} — make an earlier version live; returns a ws_path. Add "restore_data":true to put a backup's volumes and env back too. |
| Backups | ||
| GET | /projects/{name}/backups | The project's archives, newest first, with sizes and contents. |
| POST | /projects/{name}/backups | Archive it now; returns a ws_path (WS /projects/{name}/backup). |
| GET | /projects/{name}/backups/{id}/download | Raw [offset, offset+length) pieces of one archive. |
| GET | /projects/{name}/backup-config | The automatic schedule, destination and retention. PUT changes it. Admin only. |
| GET | /backups/database | The freeholdy database's own archives; POST takes one. Admin only. |
| Environment variables | ||
| GET | /projects/{name}/env | The stored file: content, the keys in it, and applied (false → restart to apply). |
| PUT | /projects/{name}/env | {"content":"KEY=value\n…"} — replaces the whole file. Stores only; 422 names the bad line. |
| DELETE | /projects/{name}/env | Delete the file. Also takes effect on the next start. |
| GET | /projects/{name}/services/{svc}/env | One compose service's own file. |
| PUT | /projects/{name}/services/{svc}/env | Set it — its values win over the project-level file. |
| DELETE | /projects/{name}/services/{svc}/env | Delete it; the shared file still applies. |
| Container logs | ||
| GET | /projects/{name}/logs?tail=N | The last N lines the container printed (compose: the whole stack, interleaved). Default 200. |
| GET | /projects/{name}/services/{svc}/logs | The same for one compose service. |
| Plugins | ||
| GET | /plugins/ | The plugin catalog, with descriptions and deploy modes. |
| POST | /plugins/{plugin}/add | {"project_name":"mycloud"} — install it; returns a ws_path. |
There is no create endpoint and no separate build or start call — a deploy does all of it, and re-deploying is how you ship an update.
Streaming logs
Browsers can't set an Authorization header on a WebSocket, so every
freeholdy socket opens the same way: your first frame must be
{"type":"auth","token":"…"}. The server replies {"type":"ready"},
streams {"type":"stdout","data":"…"} frames, and finishes with
{"type":"exit","code":N}. Shell sockets also accept stdin and
resize frames from the client.
| Socket | What it streams |
|---|---|
WS /projects/{name}/deploy | Build + run log — uploads, git deploys, and rollbacks all land here. |
WS /projects/{name}/exec | Interactive shell in the container (?cmd= overrides the default shell). |
WS /projects/{name}/services/{svc}/exec | Interactive shell in one compose service. |
WS /plugins/{plugin}/install/{project} | Plugin install log — interactive plugins prompt over this same socket. |
WS /git/deploy/{project} | Git deploy log (read-only). |
Close codes: 4401 auth failed, 4404 not found,
4409 busy. Disconnecting never kills a build — the job keeps running on the
server, and reconnecting re-streams it.
Tokens & roles
Every token carries a role, chosen when it is issued:
| Role | What it can do |
|---|---|
admin |
Everything: every project, every endpoint, plus minting and revoking tokens. The default, and what every token issued before roles existed is. |
guest |
Scoped to one or more projects: redeploy them from their own git origins, restart them, read their logs, edit their environment, list their versions and roll them back, and take, download or delete manual backups of them. Nothing else — and it cannot even see that your other projects exist. |
Mint them from the web UI's Tokens panel, from the CLI:
fhcli tokens # list
fhcli token-create my-laptop # admin
fhcli token-create gitlab-ci --role guest --project myapp
fhcli token-create ci --role guest -P frontend -P backend # several projects
fhcli token-projects 3 -P frontend # re-scope, same secret
fhcli token-revoke 3
fhcli whoami # what am I allowed to do?
…or on the server itself, from the repo root with the venv active:
python scripts/generate_token.py generate --name "ci"
python scripts/generate_token.py generate --name "gitlab-ci" --role guest --project myapp
python scripts/generate_token.py list
python scripts/generate_token.py revoke --id 3
Guest tokens (CI/CD)
A guest token is what you hand to someone who is not you — a GitLab or GitHub runner, a contractor, a client — so they can ship their projects without holding the keys to your whole server.
The shape of it:
- You deploy the project from git once as an admin. freeholdy records the repository URL and branch on the project.
- You mint a guest token scoped to that project (or several).
- The third party calls redeploy. freeholdy re-clones the origin it already recorded and cuts a new version — the runner never supplies a URL, and cannot change one.
# 1 — you, as admin: the project needs an origin to re-clone
fhcli deploy myapp https://github.com/me/myapp.git
# 2 — mint the token to hand over (repeat -P for several projects)
fhcli token-create gitlab-ci --role guest --project myapp
# 3 — what the third party runs, from then on
fhcli redeploy myapp
POST /git/add both name a source, so both are admin-only. A
guest can rebuild a project from the repo it already points at — it cannot repoint
it at a different repo, and cannot push it arbitrary files. That is the difference
between "let CI ship this app" and "let CI put anything on my server".
What the holder of that token can and cannot do:
| Allowed, on the projects in its scope | Refused (403) everywhere |
|---|---|
redeploy (re-clone its own origin) · watch the build log ·
restart · logs · status ·
abort · versions · rollback ·
read and write the .env · backup ·
backups · backup-download ·
backup-delete
|
every project outside its scope (not even listed) · uploading files ·
git/add (pointing a project at a repo) · creating or deleting
projects · stop · exec (no shell) ·
ssl · domain · the backup limit · the automatic
backup settings, schedule and destinations (reading them included) ·
importing a backup archive · the freeholdy database's own backups · the plugin
catalog and installs · the server's git key · minting or revoking tokens
|
The redeploy script. freeholdy ships
ci/redeploy.sh for exactly this job — copy it out of the
freeholdy
repository into the repository the third party actually builds from. It needs
nothing but sh and curl, so it runs in whatever image the
pipeline already uses: no freeholdy checkout, no Python, no fhcli.
It takes its settings either from the environment, or from a dotenv file passed as its one positional argument:
./redeploy.sh # settings from the environment
./redeploy.sh deploy.env # settings from that file instead
| Setting | What it is |
|---|---|
FH_TOKEN | The API token. A guest token scoped to the project is all this needs, and is what you should use. |
FH_PROJECT_NAME | The project to redeploy. |
FH_BASE_DOMAIN | Your server's base domain — the API is reached at https://api.<domain>. |
FH_API_URL | Optional. A full API base URL, overriding the line above. Set it only if your API is not at api.<domain>; when it is set, FH_BASE_DOMAIN is not needed. |
In GitLab CI, store the token as a masked variable and redeploy on every push — the whole job is one line:
deploy:
script:
- ./redeploy.sh
variables:
FH_PROJECT_NAME: myapp
FH_BASE_DOMAIN: your-domain.com
# FH_TOKEN comes from a masked CI variable, not from this file
The runner must push to the repository before it calls this: redeploy pulls whatever the branch points at right now, so a job that redeploys before it pushes will faithfully rebuild the previous commit.
Checking it without deploying. --dry-run resolves and
validates the settings exactly as a real run does — a missing file or a missing
setting still fails — then prints the equivalent curl command instead of
sending anything:
$ ./redeploy.sh deploy.env --dry-run
redeploy: would POST https://api.your-domain.com/projects/myapp/redeploy
redeploy: $FH_TOKEN is left unexpanded — export it first, or re-run with --show-token
curl -sS -X POST "https://api.your-domain.com/projects/myapp/redeploy" \
-H "Authorization: Bearer $FH_TOKEN" \
-H "Accept: application/json"
The command goes to stdout and every other line to
stderr, so ./redeploy.sh --dry-run 2>/dev/null is
just the command, and eval "$(./redeploy.sh --dry-run)" runs it. The
token is left as $FH_TOKEN, which means the printed command is safe to
paste into a ticket — and that it only runs if you have that variable exported. Add
--show-token to embed the real value instead, at which point the output
is a live credential:
$ ./redeploy.sh deploy.env --dry-run --show-token
redeploy: the command below contains a live token — treat it as a secret
curl -sS -X POST "https://api.your-domain.com/projects/myapp/redeploy" \
-H 'Authorization: Bearer <your token>' \
-H "Accept: application/json"
It exits with:
| Code | Meaning |
|---|---|
0 | The server accepted the redeploy. |
1 | A configuration problem — missing file, missing setting, bad argument. Nothing was sent. |
2 | The API call failed. The server's own message is printed, so a 403 tells you which projects the token is limited to and a 400 tells you the project was never deployed from git. |
GET /projects/<name>/status until its
status is no longer running.
None of this is required, mind — the script is a convenience wrapper. The same redeploy is one call, if you would rather not vendor a file:
curl -fsS -X POST "https://api.$BASE_DOMAIN/projects/myapp/redeploy" \
-H "Authorization: Bearer $FREEHOLDY_TOKEN"
Several projects, one token. A guest token can cover any number of projects — useful when one repo group or one team ships several apps:
fhcli token-create ci --role guest -P frontend -P backend -P worker
fhcli token-projects 4 -P frontend -P backend # narrow it later
fhcli token-projects 4 # scope it to nothing
Re-scoping does not change the secret, so the third party's CI variable keeps working — only what it reaches changes. Deleting a project unbinds it from every token that covered it; those tokens keep working for whatever else they cover, and a token left with nothing can still authenticate but can act on no project.
https://ui.<your-domain>/token/<TOKEN> link opens the panel
showing only that token's projects, with only the actions it is allowed.
CLI reference
Every fhcli command, and what it maps to. Any of them takes
--help for its full options and examples.
| Command | What it does |
|---|---|
| Getting around | |
health | Check the server is up and the token works. |
projects | List every project with its status and endpoints. |
status PROJECT | Container state plus the log of the last freeholdy operation. |
| Deploying | |
deploy NAME SOURCE | Deploy a folder or a git URL. Creates the project if new, redeploys if not. --env, --branch, --dest. Admin only. |
redeploy PROJECT | Rebuild from the git origin the project already records — no source needed. The deploy a guest token gets. |
plugins | List the plugin catalog. |
plugin-add PLUGIN PROJECT | Install a plugin as a project; interactive plugins prompt right here. |
get-git-key | Print the server's SSH public key, for cloning private repos. |
| Running | |
logs PROJECT | What the container printed. -n lines, -s one compose service. |
exec PROJECT [CMD] | Interactive shell (or one command) inside the container. |
restart PROJECT | Recreate the container(s) — no rebuild. Applies environment changes. |
stop PROJECT | Stop the container. |
abort PROJECT | Abort the build or deploy currently in flight. |
compose-status PROJECT | Stack state plus the last compose operation's log. |
compose-down PROJECT | Bring a compose stack down. |
| Environment | |
env-get PROJECT | Print (or -o save) the stored .env. -s for one service. |
env-set PROJECT FILE | Upload it (- reads stdin). Saved only — follow with restart. |
env-clear PROJECT | Delete it. |
| Versions | |
versions PROJECT | List the versions and their states. |
rollback PROJECT N | Make version N live again. |
set-backup-limit PROJECT N | Cap the archived versions and prune now. |
| Backups | |
backup PROJECT | Archive it now — image(s), volumes, env and files in one tar. --version N, --no-volumes. |
backups PROJECT | List the archives, newest first, with size and what each contains. |
backup-download PROJECT ID | Fetch one archive in chunks. -o FILE. |
backup-upload PROJECT ARCHIVE | Import an archive as a new archived version — activate it with rollback. Admin only. |
backup-delete PROJECT ID | Delete an archive; --remote also removes it at the destination. |
backup-config PROJECT | Show or change the automatic schedule, destination and retention. Admin only. |
backup-targets | List the destinations the server can see, and backup-target-test NAME checks one. Admin only. |
db-backup | The same for the freeholdy database itself — plus db-backups, db-backup-download, db-backup-delete, db-backup-config. Admin only. |
| Tokens | |
whoami | What the configured token is allowed to do (role, and the project a guest is bound to). |
tokens | List every API token. Admin only. |
token-create NAME | Mint a token, shown once. --role guest -P a -P b scopes it to those projects. |
token-projects ID | Replace which projects a guest token may act on. The token itself is unchanged. |
token-revoke ID | Revoke a token immediately. Admin only. |
| Domains & teardown | |
ssl PROJECT | Re-run certbot for the project's domains. |
domain PROJECT [DOMAIN] | Point a custom domain at it; --clear reverts to the subdomain. |
remove NAME | Full teardown — containers, images, versions, nginx config, files. |
AI integration
freeholdy ships a Claude Code skill, fhdeploy, that lets
Claude deploy to your server for you — no fhcli required. It talks to the
REST API directly: uploads a folder (or points the server at a git URL), follows the
build, and reports the live endpoints.
Setup
- Copy
skills/fhdeploy/from the freeholdy repo into your Claude Code skills directory (e.g.~/.claude/skills/). - Run it from any Claude Code session:
/fhdeploy myapp ./myapp /fhdeploy mysite https://github.com/owner/repo.git - On first use it asks for your API token (printed by the installer) and base domain, and remembers them in Claude Code memory for next time.
Beyond deploys, the skill knows the versioning API — it can list a project's versions and roll back — and the git deploy-key flow for private repos. In practice: tell Claude to build something, then tell it to ship it.