One command deploys the directory it's run in. It works the same from your computer, from a coding agent and from CI: there is no connection to GitHub or any other host to set up, and nothing about your repository is stored here but the code you push.
alf launch # the first time: sets the directory up and deploys it
alf deploy # every time after
That's the command line. It doesn't wait for the deployments: once the code is sent it says the project is being deployed and prints its link, where the deploy is followed live (--wait, or CI, waits). Where it isn't installed (CI, an agent's sandbox, a server), deploy.sh does the same with nothing to install.
The command looks at the directory:
| It finds | It deploys |
|---|---|
An alf.toml that lists services | Those, with the settings it gives them, and the databases, buckets and computers it names. |
A docker-compose file (docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml) | Every service in it: build: ones are built from your code, image: ones run that image. See docker-compose stacks. |
Otherwise a Dockerfile | One service, built from it, named after the directory (or --service <name>). |
Services the project already has get the new code. Missing ones are created: public on the port the image declares, unless the compose file says otherwise. Without an alf.toml, a push brings code, not settings: variables, domains, scaling and everything else you set in the dashboard stay as they are. With one, the settings it names are set too, and those it doesn't name stay. Nothing is ever deleted by a push.
Before anything is sent, the command prints the plan:
Project: shop
~ web: deploy the pushed code (built from web/Dockerfile)
+ worker: new service, built from worker/Dockerfile, private
= db: unchanged (postgres:17)
For what's new or resized, the plan also says what it costs: its size, so much an hour awake (and, for one that never sleeps, a month), and nothing while asleep.
Then it packs the code, sends it, and waits. From the command line, only the files that changed since the services' last push are sent (nothing at all when none did), and their server puts the code together again: a deploy of a large repository after a small change sends a few kilobytes. Your server builds the images; the build log is live in each service's Deployments tab, and a preview's services are built on the servers its project's are, where their build cache is. When a deployment fails, the command prints why, with the container's last output, and exits with an error.
What's sent. In a git repository: the repository's files, including uncommitted changes, without ignored files (so node_modules, build output and .env files in your .gitignore stay home). Elsewhere: the directory, without .git and node_modules. alf deploy also sends the files of submodules that are checked out (a site's theme, say), leaves out what the builds' .dockerignore does (Docker wouldn't see it anyway), and names any file of 50 MB or more that goes up, so data dumps and the like can be left out. Large code is fine: it goes up in parts, up to 8 GB compressed. (Gigabytes of "code" are usually data, models or build output: what the image doesn't need is better left out, and what it does need makes every deploy as slow as that upload. For the heaviest cases, build the image in CI, push it to a registry and deploy the image.) Paths are from the repository's root, so a monorepo works from any subdirectory.
cd ~/code/shop
alf deploy # what alf.toml (or the compose file, or the Dockerfile) has
alf deploy web # only the service web
alf deploy --project shop # without an alf.toml: which project
In a project, + Create → Your code (or, for one service, Deployments → Deploy new code) shows the command for it.
The same page has a one-time line, nothing to install: deploy.sh with a link good for 15 minutes, so no token is stored on the machine. It needs sh, curl and tar:
curl -fsSL https://runalf.com/deploy.sh | sh -s -- "https://api.runalf.com/api/v1/push/…"
A service's own link always deploys to that service, whatever else the directory holds. Coding agents get this line from their push_code and upload_code tools.
On GitHub, one command in the code's directory sets it up:
alf ci github
It writes the two workflows below and, once you say yes, makes a deploy token for the project for the repository's secret SMARTALFRED_TOKEN: saved there with the GitHub CLI when it's installed and signed in (the token is never shown), else shown once with the page to paste it in. See the command line.
By hand, or in another CI: CI uses a deploy token instead of a link. In + Create → Your code → From CI → Another CI, or by hand, create one for the project (or make one under Agents: deploy access, limited to the project) and save it in your CI as the secret SMARTALFRED_TOKEN. In CI the command is deploy.sh, which needs nothing installed.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to Alf
run: curl -fsSL https://runalf.com/deploy.sh | sh
env:
SMARTALFRED_TOKEN: ${{ secrets.SMARTALFRED_TOKEN }}
SMARTALFRED_PROJECT: shop
Every push to main is deployed; the job fails when the deployment does, and its summary shows what happened. The deployment carries the commit and its message.
# .github/workflows/preview.yml
name: Preview on Alf
on:
pull_request:
types: [opened, synchronize, reopened, closed]
concurrency:
group: smartalfred-preview-${{ github.event.number }}
jobs:
preview:
# Pull requests from forks get no secrets, so they're skipped.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
# The comment is written with the job's own token: nothing to install on GitHub.
permissions:
contents: read
pull-requests: write
env:
SMARTALFRED_TOKEN: ${{ secrets.SMARTALFRED_TOKEN }}
SMARTALFRED_PROJECT: shop
steps:
- uses: actions/checkout@v4
if: github.event.action != 'closed'
- name: Deploy the preview
id: deploy
if: github.event.action != 'closed'
run: curl -fsSL https://runalf.com/deploy.sh | sh -s -- --preview pr-${{ github.event.number }}
- name: Say where it is on the pull request
if: github.event.action != 'closed' && steps.deploy.outputs.urls != ''
env:
GH_TOKEN: ${{ github.token }}
URLS: ${{ steps.deploy.outputs.urls }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
marker='<!-- smartalfred-preview -->'
body="$(printf '%s\n' "$marker"; printf '**Preview** of %s, live on Alf:\n\n%s\n' "${SHA:0:7}" "$(printf '%s\n' "$URLS" | sed '/^$/d; s/^\([^ ]*\) \(.*\)$/- **\1**: \2/')")"
comments="repos/$GITHUB_REPOSITORY/issues/${{ github.event.number }}/comments"
id="$(gh api --paginate "$comments" --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1)"
if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
elif [ "${{ github.event.action }}" != closed ]; then gh api "$comments" -f body="$body" >/dev/null; fi
- name: End the preview
if: github.event.action == 'closed'
run: curl -fsSL https://runalf.com/deploy.sh | sh -s -- --end-preview pr-${{ github.event.number }}
- name: Say it ended on the pull request
if: github.event.action == 'closed'
env:
GH_TOKEN: ${{ github.token }}
run: |
marker='<!-- smartalfred-preview -->'
body="$(printf '%s\n' "$marker"; printf 'The preview of this pull request ended when it closed.\n')"
comments="repos/$GITHUB_REPOSITORY/issues/${{ github.event.number }}/comments"
id="$(gh api --paginate "$comments" --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1)"
if [ -n "$id" ]; then gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
elif [ "${{ github.event.action }}" != closed ]; then gh api "$comments" -f body="$body" >/dev/null; fi
--preview deploys to a preview of the project with that name, and makes it the first time: a copy of the project at its own addresses. The steps after it put the addresses (deploy.sh hands them on as the output urls) in a comment on the pull request, one that's updated with each push and says when the preview ended, with the job's own token: nothing to install on GitHub. Pull requests from forks get no secrets, so they're skipped.
The command is the same everywhere: check the code out, set SMARTALFRED_TOKEN, run it.
# .gitlab-ci.yml
deploy:
image: alpine/git
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- apk add --no-cache curl
- curl -fsSL https://runalf.com/deploy.sh | sh -s -- --project shop
It needs sh, curl, tar and (in a repository) git.
Each option can also be set in the environment, as SMARTALFRED_ and its name: --keep-staged is SMARTALFRED_KEEP_STAGED=1.
| Option | |
|---|---|
--project <name> | The project: its name, address part or id. Not needed with a link, a token made for one project, or an alf.toml that names it. |
--service <name> | With a Dockerfile: the service to deploy to (a new name creates it). With a compose file: only that service of it. |
--config <path> | Use this project file instead of alf.toml. |
--no-config | Leave alf.toml out. |
--compose <path> | Use this compose file instead of the one found. |
--profile <names> | The compose file's profiles to deploy, comma-separated. Its services in other profiles are left out, as compose does. |
--dockerfile <path> | Use this Dockerfile; a compose file and alf.toml are ignored. |
--context <dir> | The directory to build in. Default: the Dockerfile's. |
--preview <name> | Deploy to the project's preview of that name, made if it isn't there. |
--end-preview <name> | End that preview, and do nothing else. |
--candidate | Start new versions beside the live ones and wait to be promoted. |
--keep-staged | Leave staged variable and secret changes staged. By default they go live with the deployment. |
--config-wins | Settings changed in the dashboard since alf.toml set them go back to the file's. By default they stay, and the deploy says which. |
--group <name> | Show newly created services as one group. |
--message <text> | Shown with the deployment. Default: the last commit's subject. |
--plan | Print the plan and stop: nothing is sent or changed. |
--no-wait | Don't wait for the deployments. |
A directory can be given last: … | sh -s -- --project shop ./apps/web.
For a new service it's the Dockerfile in the directory the command runs in, built in that directory. After the first push the service remembers where its Dockerfile is, so pushing from another directory of the same repository still builds the right one. --dockerfile (and --context) change it; so do the Dockerfile and Build directory fields in the service's settings.
+ Create → Service → Your code makes a service without deploying anything: useful when you want to set its variables, secrets or volumes first. It's deployed with the first push to it.