# Deploy with GitLab CI and restart a service
This example builds a Node.js project, uploads the build over password-authenticated SFTP, and restarts one Zaroz Cloud service through the API. The SFTP password transfers files; a separate fine-grained API token controls only the restart.

The calls used are `POST /service-instances/{id}/restart` and `GET /service-instances/{id}/resources` from the [API reference](/api). To deploy a container image instead of uploading files, see [Deploy from CI/CD](/guides/ci/overview).

## What you need

From the service's page in the console, collect the SFTP host, port, username, password, and destination path.

Create a dedicated API token with these two grants, both scoped to the exact service being deployed (its service instance id, as in the console URL). See [API tokens](/api-tokens/getting-started) for how to scope a token:

- `services.read` — lets the job wait for the server to come back.
- `services.power` — lets the job restart the server.

Do not give a deployment token terminal, billing, database, or account-wide access.

## Add protected CI/CD variables

In GitLab, open **Settings → CI/CD → Variables** and add:

| Variable | Type | Recommended flags |
| --- | --- | --- |
| `ZAROZ_API_TOKEN` | Variable | Masked, protected |
| `ZAROZ_SERVICE_ID` | Variable | Protected |
| `ZAROZ_SFTP_HOST` | Variable | Protected |
| `ZAROZ_SFTP_PORT` | Variable | Protected |
| `ZAROZ_SFTP_USER` | Variable | Protected |
| `ZAROZ_SFTP_PATH` | Variable | Protected |
| `ZAROZ_SFTP_PASSWORD` | Variable | Masked, protected |
| `ZAROZ_SFTP_KNOWN_HOSTS` | File | Protected |

Generate `ZAROZ_SFTP_KNOWN_HOSTS` from a trusted computer and verify the displayed host fingerprint before storing it. Do not use `StrictHostKeyChecking=no` in a deployment pipeline.

:::warning{title="Protect the deployment environment"}

Protected variables are only available to protected branches or tags. Configure GitLab's production environment protection so an untrusted merge request cannot exfiltrate either credential.

:::

## Add `.gitlab-ci.yml`

This example expects `npm run build` to produce a `dist/` directory. Change the build command and local directory for your project.

```yaml title=".gitlab-ci.yml"
stages:
  - build
  - deploy

build:
  stage: build
  image: node:22-alpine
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour

deploy-production:
  stage: deploy
  image: alpine:3.22
  needs:
    - job: build
      artifacts: true
  environment:
    name: production
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
  before_script:
    - apk add --no-cache curl jq lftp openssh-client
    - install -d -m 700 ~/.ssh
    - cp "$ZAROZ_SFTP_KNOWN_HOSTS" ~/.ssh/known_hosts
    - chmod 600 ~/.ssh/known_hosts
  script:
    - |
      export LFTP_PASSWORD="$ZAROZ_SFTP_PASSWORD"
      lftp -e "
        set cmd:fail-exit true;
        set sftp:connect-program \"ssh -a -x -o StrictHostKeyChecking=yes\";
        open --env-password --user \"$ZAROZ_SFTP_USER\" -p \"$ZAROZ_SFTP_PORT\" \"sftp://$ZAROZ_SFTP_HOST\";
        mirror --reverse --verbose --no-perms dist/ \"$ZAROZ_SFTP_PATH\";
        bye
      "
      unset LFTP_PASSWORD
    - |
      api() {
        curl --fail-with-body --silent --show-error \
          -H "Authorization: Bearer $ZAROZ_API_TOKEN" \
          -H 'Accept: application/json' \
          "$@"
      }

      API="https://kernel.zaroz.cloud/api/v2/service-instances/$ZAROZ_SERVICE_ID"

      # A restart in place: no stopped state to race against.
      api -X POST "$API/restart" > /dev/null
      sleep 5

      ready=false
      for attempt in $(seq 1 30); do
        state="$(api "$API/resources" | jq -r '.data[] | select(.is_declared_primary) | .status')"
        echo "Waiting for the server: $state"

        case "$state" in
          running)
            ready=true
            break
            ;;
          failed|crash_looping)
            echo "The service is $state after the restart"
            exit 1
            ;;
        esac

        sleep 2
      done

      if [ "$ready" != true ]; then
        echo "The service did not come back within about 65 seconds"
        exit 1
      fi

      echo "Deployment uploaded and service restarted"
```

## Why the job restarts in place and then waits

`restart` bounces the server without passing through a stopped state that your script would have to observe, so there is no stop-then-start race. The restart takes time to finish, so the job reads the primary resource's status until it is `running`, and gives GitLab a useful failure reason if it becomes `failed` or `crash_looping` instead.

For software that supports graceful reloads, prefer its own reload mechanism. A stop/start cycle interrupts active players or users.

## Adapt the upload

The SFTP batch above overwrites matching files but does not delete remote files that disappeared from `dist/`. If your release must be an exact mirror, deploy into a versioned directory and atomically switch releases from your application's own startup script.

Never upload secrets from the repository. Keep runtime secrets in the Zaroz dashboard or protected CI/CD variables.

## Troubleshooting

<details>
<summary>The API returns 403</summary>

Confirm both token grants are scoped to the same UUID in `ZAROZ_SERVICE_ID`. The owning user must also still be allowed to control that service.

</details>

<details>
<summary>SFTP reports a host-key error</summary>

Update `ZAROZ_SFTP_KNOWN_HOSTS` only after verifying the new fingerprint through a trusted channel. Do not disable verification.

</details>

<details>
<summary>The upload works but the restart does not</summary>

Check that `services.power` was selected, not only `services.read`, and that the token has not expired.

</details>

<details>
<summary>The server does not return to running within about a minute</summary>

Let the job fail rather than repeatedly restarting it. Inspect the service in the console (and its [logs](/guides/services/operate#logs)) and retry the deployment once the cause is fixed.

</details>
