Deploying your code

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.

What it deploys

The command looks at the directory:

It findsIt deploys
An alf.toml that lists servicesThose, 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 DockerfileOne 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.

From your computer

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.

Without the command line

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.

From CI

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 Actions

# .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.

A preview for every pull request

# .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.

Any other CI

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.

Options

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-configLeave 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.
--candidateStart new versions beside the live ones and wait to be promoted.
--keep-stagedLeave staged variable and secret changes staged. By default they go live with the deployment.
--config-winsSettings 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.
--planPrint the plan and stop: nothing is sent or changed.
--no-waitDon't wait for the deployments.

A directory can be given last: … | sh -s -- --project shop ./apps/web.

Where the Dockerfile is

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.

A service that waits for its code

+ 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.

Tokens and safety