---
title: "Custom Build Commands and GitHub Actions Deploys"
description: "Want a build step before a deploy, or only deploy code that passed its tests? How a deploy script works and how GitHub Actions can start a deploy."
url: https://fps.ms/docs/containers/deploy-scripts-and-github-actions
---
# Custom build commands and CI deploys

Every GitHub deploy on FPS.ms runs a deploy script: a short Bash script that fetches your commit and installs your packages before the server restarts. You can add your own build steps to it on the **Script** tab, for example `npm run build` or a database migration. To deploy only code that passed its tests, let GitHub Actions push to the branch your server deploys, or call the CI webhook URL from your workflow. This guide assumes you already [deploy from GitHub](https://fps.ms/docs/containers/deploy-from-github.md).

## What the default script does

Every runtime starts with a script that works for most projects. It fetches the commit you pushed, then runs the install or build step below. When it finishes, the server restarts and starts your app.

| Runtime | What the default script runs |
|-|-|
| Python | `pip install` from `requirements.txt` |
| Node.js | `npm ci` when there is a `package-lock.json`, otherwise `npm install` |
| Bun | `bun install` |
| .NET | `dotnet publish` on your `.csproj` |
| Dart | `dart pub get`, then `dart compile exe` |
| Go | `go build` |
| Java | `mvn package` with a `pom.xml`, otherwise a Gradle build |
| Kotlin | A Gradle build, or `kotlinc` when there is no Gradle project |
| Rust | `cargo install` from your crate |
| Haskell | `cabal build`, or `stack build` when there is no `.cabal` file |
| Lua | `luarocks install` for your `.rockspec` and extra rocks |
| Odin | `odin build` |

## Add your own build steps

1. Click **Manage** on your server, then **More** and **Deployments**.
2. Click **Script**. The editor shows your server's deploy script.
3. Add your commands at the end. Keep the lines that fetch the commit at the top.
4. Click **Save**. The new script is used from the next deploy.

For example, a Node.js app that compiles TypeScript ends its script like this:

```bash title="Deploy script"
# Install dependencies.
if [ -f package-lock.json ]; then
    npm ci --no-audit --no-fund
elif [ -f package.json ]; then
    npm install --no-audit --no-fund
fi

# Compile TypeScript into dist/
npm run build
```

And a Django app can run its migrations on every deploy:

```bash title="Deploy script"
python manage.py migrate
python manage.py collectstatic --noinput
```

The script stops at the first command that fails, and the deploy is marked as failed. The [build log](https://fps.ms/docs/containers/deploy-from-github.md) shows which line it was.

!!! tip "Back to the default" "Click **Reset to egg default** to throw away your changes and use the runtime's default script again."

### Variables you can use in the script

| Variable | What it holds |
|-|-|
| `$RELEASE_DIR` | The folder the deploy builds in: your server's main folder |
| `$COMMIT_SHA` | The commit being deployed |
| `$BRANCH` | The branch that was pushed |
| `$DEPLOYMENT_ID` | A unique ID for this deploy |

Your [environment variables](https://fps.ms/docs/containers/environment-variables.md) are available in the script too, for example an API key a build step needs.

## Deploy only code that passed its tests

A push to the connected branch deploys right away. To deploy only tested code, let your tests decide what reaches that branch:

1. Connect your server to a branch that only your workflow writes to, for example `production`.
2. Run your tests on every push to `main` in GitHub Actions.
3. When they pass, the workflow pushes the commit to `production`, and FPS.ms deploys it.

```yaml title=".github/workflows/deploy.yml"
name: Test and deploy

on:
  push:
    branches: [main]

permissions:
  contents: write

jobs:
  test-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - run: python -m pytest
      - name: Deploy tested code
        run: git push origin HEAD:production
```

If the push does not start a deploy, add the webhook call from the next section after the `git push` step.

## Start a deploy from CI with the webhook URL

Every connected server also has a CI webhook URL. Calling it starts a deploy of the newest commit on the connected branch, or of the commit you name. Use it when a deploy should wait for something else, like a release you publish by hand.

1. On the **Deployments** tab, click **Settings**.
2. Under **CI webhook URL**, copy the **Webhook URL**.
3. Click **Rotate deploy token** and copy the token. It is only shown once.
4. In GitHub, open your repository's **Settings**, then **Secrets and variables** and **Actions**. Add two secrets: `FPS_DEPLOY_URL` with the URL and `FPS_DEPLOY_TOKEN` with the token.
5. Add a step that calls the URL:

```yaml title="Deploy step"
      - name: Deploy to FPS.ms
        run: |
          curl --fail -X POST "$FPS_DEPLOY_URL" \
            -H "Authorization: Bearer $FPS_DEPLOY_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"commit_sha\": \"$GITHUB_SHA\"}"
        env:
          FPS_DEPLOY_URL: ${{ secrets.FPS_DEPLOY_URL }}
          FPS_DEPLOY_TOKEN: ${{ secrets.FPS_DEPLOY_TOKEN }}
```

Leave out the `-d` line to deploy the newest commit on the branch.

!!! warning "Auto-deploy must be on" "The webhook URL only works while **Auto-deploy** is on, so pushes to the connected branch deploy too. If you want CI to be the only way in, connect a branch nobody pushes to by hand."

If the call fails, the answer says why:

| Answer | Meaning |
|-|-|
| `401` `invalid_token` or `missing_token` | The token is wrong, revoked or not sent |
| `409` `auto_deploy_disabled` | Turn **Auto-deploy** on |
| `422` `server_not_bound` | No repository is connected to the server |
| `429` `deploy_rate_limited` | Too many deploys in a short time; wait and try again |

To stop old tokens from working, click **Rotate deploy token** again or **Revoke token**.

## Next steps

- [Deploy from GitHub](https://fps.ms/docs/containers/deploy-from-github.md)
- [Supported runtimes](https://fps.ms/docs/containers/supported-runtimes.md)
- [Fix an app that will not start](https://fps.ms/docs/containers/troubleshooting.md)

---

[FPS.ms](https://fps.ms/index.html.md) · [All pages](https://fps.ms/llms.txt) · [Documentation](https://fps.ms/docs/llms.txt)
