# Deploy a project to AWS from a task

Let the Borago Board agent build a static site and deploy it to S3 and CloudFront from one task, with the AWS keys held as encrypted environment variables.

A deploy is a job with a clear end, a command-line tool and a credential. That makes it a good task for the agent in Borago Board, as long as the credential never appears in the task. This tutorial gives an IAM user a policy that can touch one bucket and one distribution, puts its keys into the project as environment variables the agent uses without their values ever being written into the task or the repository, makes the AWS command-line tool present in the container, and then hands the deploy to one task.

## You will build

A board task that builds your static site inside the project's container, syncs the output folder to an S3 bucket, invalidates the CloudFront distribution in front of it and prints the site address. The keys are stored encrypted, injected into the container only when a command runs, and never written into the task, the prompt or the repository.

## You need

- Borago Board installed with a project whose container is running, see [Getting started in ten minutes](/docs/guides/getting-started/). The project's build must write a folder of static files, such as `dist/` from a Vite or similar build.
- An AWS account with an S3 bucket already created and a CloudFront distribution already serving that bucket. AWS's own guides cover both, [hosting a static website on S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html) and [creating a CloudFront distribution](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/GettingStarted.SimpleDistribution.html). Note the bucket name, the distribution id, your account id and the distribution's address.
- The bucket name, the distribution id and the site address are identifiers and may sit in the task. The two keys are secrets and must not.

## Steps

### 1. Create an IAM user that can do exactly this job

In the AWS console open IAM, create a user for this deploy and give it the policy below as an inline policy. Replace the three capitalised placeholders, one of which appears twice, with your bucket name, your account id and your distribution id. Then create an access key for the user, choosing the command-line use case, and copy both halves of it somewhere safe. AWS shows the secret key once.

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListTheBucket",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME"
    },
    {
      "Sid": "WriteTheBucket",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
    },
    {
      "Sid": "InvalidateTheDistribution",
      "Effect": "Allow",
      "Action": ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
      "Resource": "arn:aws:cloudfront::YOUR-ACCOUNT-ID:distribution/YOUR-DISTRIBUTION-ID"
    }
  ]
}
```

What happens. The policy lets the key list one bucket, write and delete objects in it, and create and read invalidations on one distribution. It cannot read other buckets, create resources or change the distribution. Anything the agent runs inside the container runs with this key, so the policy is the boundary that limits what a wrong command can do. The agent has no AWS access at all until step 2.

### 2. Put the keys into the project as environment variables

On the board press **Edit project**, the pencil in the filter bar, and open the **Environment Variables** tab. Press **+ Add variable** three times and fill the rows.

- **Name** `AWS_ACCESS_KEY_ID`, **Value** the access key id. Tick **Write only (Never show again)**.
- **Name** `AWS_SECRET_ACCESS_KEY`, **Value** the secret access key. Tick **Write only (Never show again)**.
- **Name** `AWS_DEFAULT_REGION`, **Value** the region of your bucket, such as `eu-west-1`. Leave the boxes unticked.

Press **Save**. The two key rows now read **Stored - type to replace** and their show and copy buttons are disabled. The region row stays readable.

What happens. Every value is encrypted at rest under a key the cloud service never stores. A write-only value keeps working in every run and is never sent back to any screen again, this tab included. Leaving its box empty on a later save keeps it, and typing into the box replaces it. The values are injected into the process environment of each command the board starts inside the container, at the moment it starts. They are never written into the Dockerfile, the compose file, the task text or the prompt sent to the model. Nothing needs a restart. The next run, terminal tab or script sees the three variables. The full model of what is protected is in [Secrets and environment variables](/docs/guides/secrets-and-environment-variables/).

### 3. Or link the keys from the vault, so several projects share them

If more than one project deploys with the same key, save it once in the key vault instead. Open the key list from the key button in the top bar, or from Settings, then Keys & credentials, then **Manage** beside **Saved keys**. Press **Save a new key**, give it a **Name** such as `aws-deploy-secret`, paste the secret access key under **Value** and press **Save key**. Do the same for the access key id. Back in the project's **Environment Variables** tab, type the variable name in a row and pick the key from the **Saved key** list beside it. The typed box is disabled and reads **From a saved key**. Press **Save**.

What happens. The saved key belongs to the account, not to a project, and the project stores only a reference to it. The value is resolved on the machine that runs the container at the moment of use, and there is no screen that shows it. Saving a value needs the desktop app or a browser on the local network. Linking one works from the cloud browser and the phone too. Renaming the variable moves the link. Removing the row drops it. A key deleted from the list leaves the row marked **This saved key no longer exists** rather than silently running without a credential.

### 4. Make the AWS command-line tool present in the container

The generated container image has curl and unzip but no AWS tool, and the container runs commands as its own non-root user. Ubuntu 24.04 and later ship no `awscli` package, so a package install is not the way. Use a startup script that fetches Amazon's own installer and installs it into the user's home, which needs no administrator rights and is on the container's path already.

In the project dialog open the **Scripts** tab. On an empty tab press **Add startup script**, otherwise press **+ Add script** and set its **Trigger** to **Startup**. Name it `aws-cli`, leave **Container** on the project's own container, and paste this as the script.

```sh
if ! command -v aws >/dev/null 2>&1; then
  case "$(uname -m)" in
    x86_64) arch=x86_64 ;;
    aarch64|arm64) arch=aarch64 ;;
    *) echo "No AWS CLI build for $(uname -m)"; exit 1 ;;
  esac
  cd /tmp
  curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${arch}.zip" -o awscliv2.zip
  unzip -q -o awscliv2.zip
  ./aws/install --install-dir "$HOME/.local/aws-cli" --bin-dir "$HOME/.local/bin"
  rm -rf awscliv2.zip aws
fi
aws --version
```

Press the play button on the script's card to run it now, before saving. The output appears under the editor and ends with the tool's version line. Then press **Save**.

What happens. The script installs AWS CLI version 2 from Amazon's installer, choosing the x86_64 or aarch64 download from the container's own architecture, and only when no `aws` command is already present. It runs again at every start the board performs, after any git clone, so the tool is back after a restart or a rebuild, and it is a quick no-op when the tool is already there. A script's output streams to the board and is never stored. A failing startup script raises a notice in the bell and does not stop the container. The other route is the **Dockerfiles** tab, where the same download and install can be added as build steps above the line that switches to the non-root user, followed by **Rebuild container**. A tool installed that way is part of the image and needs no script. Either route works, and the script is the one that needs no rebuild. The rules for scripts are in [The terminal, scripts and the file browser](/docs/guides/terminal-scripts-and-files/).

### 5. Write the deploy task

Press **+ Add task** in the **Planning** column. Give it the title `Deploy the site to AWS` and paste the description below, replacing the placeholders with your bucket name, distribution id and site address, and the build command and output folder with your project's own. Leave **Review this task after saving** ticked and press **Add task**.

```
Deploy the site to AWS.

1. Install dependencies and build the site with: npm run build
   The build writes static files to dist/.
2. Sync dist/ to the S3 bucket YOUR-BUCKET-NAME with
   aws s3 sync dist/ s3://YOUR-BUCKET-NAME/ --delete
   so that files no longer in the build are removed from the bucket.
3. Create an invalidation for the path /* on the CloudFront distribution
   YOUR-DISTRIBUTION-ID and wait until it completes.
4. Print the site address: https://YOUR-SITE-ADDRESS/

The AWS credentials and region are already in the environment as
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION. Use them
as they are. Never print, echo, log or write them to any file.

Check: a GET request to https://YOUR-SITE-ADDRESS/ answers 200 and the
response body contains the title from the built index.html.
```

What happens. The task names every command, the identifiers, and a check the agent can run itself before declaring the task done. It tells the agent that the credentials are in the environment, and forbids printing them. That last line matters because run output is stored on the task, is carried into the next run's prompt on a retry, and is synced to the cloud as a readable row when cloud sync is on. A key the agent echoes as it stands is replaced by `[redacted:NAME]` before it is stored, but the model still sees what a command prints, a very short value is left as it is, and a key the agent has transformed first is not recognised, so the task forbids printing it at all. Within seconds of saving, the pre-run review may leave a comment on the card. A question holds the card until you answer it in the comments.

### 6. Move the card to To Do and watch the run

Drag the card into the **To Do** column, or open it and press **Move to Todo**. The card moves to **In Progress**, turns amber and shows a spinner, the model's name and a timer. Click the card. A section headed with your provider's name and the word **Output** shows the agent's text as it works. **Run in Background** closes the dialog without touching the run.

What happens. The queue claims the task, checks that the container is running, and starts a fresh conversation with the model. Every command the agent runs executes inside the project's container, with the three variables in its environment. The agent builds, syncs, invalidates and runs the check. The output is saved to the task as it goes, so closing the dialog loses nothing. One task runs at a time per project. The stages of a run are on [How a run works](/docs/reference/how-a-run-works/).

### 7. Read the summary

When the card lands in **Done**, open it. The newest entry under **Comments** is the agent's own summary. It names the site address and the result of the check. **Run History (1)** keeps the full output, and the **Token usage** line shows the tokens and the estimated cost of this run.

What happens. The status the agent declared becomes the card's column, and its closing comment becomes the summary. A task that could not finish lands in **Blocked** with the reason as a comment, and a retry starts from the beginning with the previous output as background. If the agent stopped without declaring an outcome, the card still lands in Done, but the summary says so and an action marker asks you to check.

## Check it worked

- Open the printed site address in your browser. The new build is what you see. CloudFront can take a minute or two after the invalidation to serve the new files everywhere.
- In the task's **Output** or **Run History**, find the sync lines listing the uploaded files and the check's `200`.
- In the AWS console, the distribution's Invalidations tab lists a completed invalidation for `/*`, and the bucket holds exactly the files of the build.
- Open the project's **Environment Variables** tab once more. The two key rows still read **Stored - type to replace**. Nothing on the task, in a comment or in the output contains either key.

If the run reports that `aws` was not found, the startup script did not run in this container yet. Press **Scripts** in the filter bar and press the play button on the `aws-cli` row, then drag the card back to To Do. If the run reports an access denied error from AWS, the policy's placeholders or the bucket's region do not match what the task names.

## Variants

The same shape carries to other hosts. A host that authenticates with a token, such as a static hosting service or a container registry, takes the token as one write-only environment variable and its own command-line tool in the startup script. A server reached over SSH takes the private key saved once in the vault and linked into a variable, which the task writes to a key file with owner-only permissions before connecting, and never prints. A deploy run on a schedule instead of on a task is the next tutorial.

## Where to go next

- [A nightly job with a flow](/docs/tutorials/nightly-flow/) runs a job every night without a task.
- [Secrets and environment variables](/docs/guides/secrets-and-environment-variables/) is the complete guide to the two flags, the vault and what is and is not protected.
- [Security](/security/) is the authority on what the cloud can and cannot read.

Related: [Secrets and environment variables](/docs/guides/secrets-and-environment-variables/), [The terminal, scripts and the file browser](/docs/guides/terminal-scripts-and-files/), [Writing tasks the agent can do](/docs/guides/writing-tasks/), [How a run works](/docs/reference/how-a-run-works/), [A nightly job with a flow](/docs/tutorials/nightly-flow/)
