Deploying on Netlify via GitHub Actions: A Seamless Guide
Tags: netlify, githubactions, deploy, javascript
Published: 2024-01-04 11:14:35

Netlify is a fantastic platform.

As developers, sometimes we don't want to spend much time figuring out how to deploy our apps, setting up servers, AWS accounts, etc. Here's where products like Netlify shine by abstracting all this complexity away from us and providing easy-to-use dashboards.

But all these facilities come with a price.

On Netlify, for example, if we use the basic and free plan, we have a few limitations, like the number of contributors we can have, the amount of network bandwidth, how many websites we can host, etc.

One that recently pissed me off a lot is the amount of build minutes.

I have a few projects at the same account (team), plus some automated processes to update my dependencies, which drained my build minutes in two weeks.

What made me angry was that Netlify thought it would be a good idea to charge me 7 dollars automatically (without giving me a chance to say NO) for extra build minutes. Because I didn't have a credit card, my account was almost suspended until I paid it.

So, I've decided to do something about it, which is being away to use their infrastructure to run my builds, and here we are.

Let's dive deep into how to handle the builds through GitHub Actions.

---

## Netlify host, builds, and previews

When we integrate a project directly from our GitHub and use Netlify as a host, we have to specify a bunch of info, including how to build our app and where the dist files are located.

By doing that, on every push in our GitHub repository, our code will be synced with Netlify, which will install dependencies, run the build, and deploy. Depending on your tools and project size, this can go from a few seconds to minutes.

By default, every project has a very nice feature called build preview, which builds your project and hosts it on another URL, making it possible to see a preview of your app with the changes you have made.

This is incredible, but it hits hard on the build times.

Imagine your project takes 1 minute to build, and you push 10 times to your feature branch. For a single feature, we're talking about 3%~ of your monthly build minutes quota.

If you have something like Dependabot or Renovate that creates PRs automatically to update dependencies, then you almost have no control over how fast the quota can go.

There are ways to mitigate that, though. [You can turn off the deploy preview](https://docs.netlify.com/site-deploys/deploy-previews/#configure-deploy-previews), which sucks because this is such a nice feature.

Also, if you're in a monorepo, you can prevent building a preview if the code touched isn't part of the project hosted on Netlify using the [ignore builds feature](https://docs.netlify.com/configure-builds/ignore-builds/#app).

Or if you already use GitHub Actions to do the deployment, in case you already use it to run checks, tests, or code quality.

---

## Deploying to Netlify through GitHub Actions

To demonstrate how to do that, I'm going to use the following setup, but you can abstract the concept and adapt it to your tooling:

- Node v20.10.0
- npm v10.2.3 (to lower the entry bar)
- vite + svelte starter project (build static)
- netlify-cli v17.10.1

### Creating the basic Github Action Workflow

With the project created, let's jump straight to where the magic will happen.

I will create a deploy workflow, but feel free to hook the deployment steps into your existing workflow.

**.github/workflows/deploy.yml**

```yaml
name: Deploy website

on: [push]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Repository Checkout
        uses: actions/checkout@v4

      - name: Setup NodeJS
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install Dependencies
        run: npm ci

```

Breaking it down:

- on every push
- We run on an ubuntu machine the following steps:
  - do a git checkout to fetch the code
  - setup node JS using version 20 and use npm cache strategy
  - install our project dependencies

When we push this code, the pipeline should run successfully:

![Successful workflow run](https://cdn.sanity.io/images/gc3hakk3/production/1590255616d830a59385a6c37948f42f2cce6b7c-2068x2024.png?rect=0,1,2068,2023&w=1300&h=1272&auto=format)

Ok, before we finish this setup, let's create a project on Netlify.

### Netlify project

I don't want to create a connection between my Github project and Netlify or drop build files there. So, to cut to the chase, I will use the Netlify CLI to create the project.

The first step [is installing the Netlify CLI](https://docs.netlify.com/cli/get-started/) globally on my machine:

```bash
npm install netlify-cli -g
```

Now, we need to log in with our account:

```bash
netlify login
```

They will ask you to open a link in your browser and log in.

After that, we can create the project:

```bash
netlify sites:create
```

You'll need to fill in some information, like the team and site names.

After finishing, you'll have your site details, such as your Admin URL, Site URL, and Site ID.

```
$ netlify sites:create
? Team: test
? Site name (leave blank for a random name; you can change it later): netlify-deploy-gha

Site Created

Admin URL: https://app.netlify.com/sites/netlify-deploy-gha
URL:       https://netlify-deploy-gha.netlify.app
Site ID:   87c1c6cb-5eae-4797-8c20-58608f633a98

Adding local .netlify folder to .gitignore file...
Linked to netlify-deploy-gha
```

If you open your Admin URL, you will see an empty project because we haven't done a deployment yet.

Before going back to the GitHub part, we'll need to save two pieces of information we'll use there: the Site ID and generate an API token.

The Site ID you already have, so now let's create an API Token.

#### Generate Netlify API Token

> **Attention**
> If for some reason you can't follow this section, search on Netlify about **_"Personal access tokens"_**

First, access your **_User Settings_**, then click on **_Applications_**. There, you'll find a section called **_Personal Access Tokens_**. Click on **_New access token:_**

![New access token](https://cdn.sanity.io/images/gc3hakk3/production/b55259ebbcbf42c4d321e1a80068e8ff88601c47-2356x2088.png?w=1300&h=1152&auto=format)

Now, give your token a name and choose when it expires:

![Token details](https://cdn.sanity.io/images/gc3hakk3/production/409f92a37c3df71f26cac1240b27226a33ff97d1-2356x2088.png?w=1300&h=1152&auto=format)

After clicking to create your token, copy the value and save it somewhere:

![Token created](https://cdn.sanity.io/images/gc3hakk3/production/1a3500ca3bc962ccfe85cadff164cdbe3fb78f31-2356x2088.png?w=1300&h=1152&auto=format)

Now, let's head back to the GitHub actions part:

### Deploy setup

To do the deployment, we have to add a few steps to our workflow.

We need to:

- Ensure we have Netlify CLI globally in our environment
- Run the build command so we can send those files to Netlify

```yaml
name: Deploy website

on: [push]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Repository Checkout
        uses: actions/checkout@v4

      - name: Setup NodeJS
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install Netlify
        run: npm install netlify-cli@17.10.1 -g

      - name: Install Dependencies
        run: npm ci

      - name: Build project
        run: npm run build

```

> I always install pinned versions because a major version update e.g., from 17 to 18, could suddenly break my setup.

Before adding the deploy command, we need to add the Site ID and the API Token to our project environment variables.

These values are secrets and can't be hard-coded in our code.

#### Adding GitHub secrets

In your project, click on **_Settings_**, then **_Secrets and variables_,** Actions, and **_New repository secret_**.

Create two variables:

- `NETLIFY_SITE_ID` => will hold your Netlify Site ID
- `NETLIFY_API_TOKEN` => will hold your Netlify API personal token

![GitHub actions secrets](https://cdn.sanity.io/images/gc3hakk3/production/d1f839319dc2d5124d9373fe5910954df2407d47-2060x1892.png?rect=0,1,2060,1890&w=1300&h=1193&auto=format)

#### Deploy command

Finally, let's add the deploy command.

Before doing that, you must know what folder is produced when you run your build command.

On Vite's projects, usually, this folder is called _dist_, but in other frameworks, it could be something like _build_.

```yaml
name: Deploy website

on: [push]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Repository Checkout
        uses: actions/checkout@v4

      - name: Setup NodeJS
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install Netlify
        run: npm install netlify-cli@17.10.1 -g

      - name: Install Dependencies
        run: npm ci

      - name: Build project
        run: npm run build

      - name: Deploy to Netlify
        id: netlify_deploy
        run: |
          netlify deploy \
            --dir dist \
            --site ${{ secrets.NETLIFY_SITE_ID }} \
            --auth ${{ secrets.NETLIFY_API_TOKEN }}
```

If you configured everything correctly, when we push this file, the pipeline will run and deploy a preview of your site.

![GHA deploy step info](https://cdn.sanity.io/images/gc3hakk3/production/452c1b9a199f1edce6328fc525d5df083d659e8d-2282x2104.png?rect=0,1,2282,2103&w=1300&h=1198&auto=format)

> The field '_Website draft URL_' holds the deployment preview URL.

![Website deployed](https://cdn.sanity.io/images/gc3hakk3/production/ab59553f4e376fcad8c9f4d38778960394ab538d-1736x2112.png?rect=0,1,1736,2111&w=1300&h=1581&auto=format)

Though it's working, we are not deploying to production yet.

Let's do some tweaks to consider this case.

### Production deployment

Deploying previews is useful only when pushing to any branch but our main branch.

Also, if we want to deploy to production, we must pass a `--prod` flag.

So, we can do some bash code with these two pieces of information.

**.github/workflows/deploy.yml**

```yaml
name: Deploy website

on: [push]

env:
  BRANCH_NAME: ${{ github.ref_name }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Repository Checkout
        uses: actions/checkout@v4

      - name: Setup NodeJS
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install Netlify
        run: npm install netlify-cli@17.10.1 -g

      - name: Install Dependencies
        run: npm ci

      - name: Build project
        run: npm run build

      - name: Deploy to Netlify
        id: netlify_deploy
        run: |
          prod_flag=""
          if [ "$BRANCH_NAME" = "main" ]; then prod_flag="--prod"; fi
          netlify deploy \
            --dir dist \
            --site ${{ secrets.NETLIFY_SITE_ID }} \
            --auth ${{ secrets.NETLIFY_API_TOKEN }} \
            $prod_flag

```

Breaking the changes down:

- on lines 5 and 6, we declare that, in all steps, we'll have access to an environment variable called BRANCH_NAME. We'll use it to determine if it's the main branch or not
- on line 33, we create a bash variable called `prod_flag`
- on line 35, we assert against our BRANCH_NAME. If it's main or whatever your main branch is named, we add the `--prod` to the prod_flag variable
- on line 39, we append the prod_flag variable. It might be `--prod` or an empty string (for deploy previews)

Our "Deploy to Netlify" step will produce a different out when we push these changes. Instead of "**Website draft URL**", we'll have "**Website URL**", the production one.

```plaintext
# ...

Build logs:        https://app.netlify.com/sites/netlify-deploy-gha/deploys/6590b2e90ce0fbbf1abd9ac1
Function logs:     https://app.netlify.com/sites/netlify-deploy-gha/functions
Unique deploy URL: https://6590b2e90ce0fbbf1abd9ac1--netlify-deploy-gha.netlify.app
Website URL:       https://netlify-deploy-gha.netlify.app
```

Which means we accomplished what we wanted.

### Bonus: Pull Request URL comment

When we integrate GitHub + Netlify and open a PR, they have a bot that gets the Deploy preview URL and posts a comment.

This is nice because right now if we want to see the URL of our preview, we need to click to see the details of our runner, expand the "Netlify Deploy" step, search for "Website draft URL", copy the link, and open it.

In other words, we kinda lost a very nice DX by handling the integration, and that means we must do something about it.

Again, there are many ways you can approach this, I'll just give a suggestion by adding a few more steps with some bash code and JavaScript via actions/github-script.

#### Getting the Website draft URL

We need to get the URL of our draft so we can comment on our PR.

Let's save the output of our deploy command into a file:

**.github/workflows/deploy.yml**

```diff
#... rest of file

 - name: Deploy to Netlify
   id: netlify_deploy
   run: |
     prod_flag=""
     if [ "$BRANCH_NAME" = "main" ]; then prod_flag="--prod"; fi
     netlify deploy \
       --dir dist \
       --site ${{ secrets.NETLIFY_SITE_ID }} \
       --auth ${{ secrets.NETLIFY_API_TOKEN }} \
-      $prod_flag
+      $prod_flag \
+      > deploy_output.txt
```

With > deploy_output.txt, every output produced will be saved in the `deploy_output` file.

Now, let's add another step that will read this file, extract the URL, and make it available for the following steps:

**.github/workflows/deploy.yml**

```yaml
# ... rest of the file ...

- name: Generate URL Preview
  id: url_preview
  if: ${{ env.BRANCH_NAME != 'main' }}
  run: |
    NETLIFY_PREVIEW_URL=$(cat deploy_output.txt | grep "Website draft URL: " | cut -d' ' -f4)
    echo "NETLIFY_PREVIEW_URL=$NETLIFY_PREVIEW_URL" >> "$GITHUB_OUTPUT"

```

Breaking it down:

- we must define an id so we can get the output in another step
- in the **_if_** clause, we only run this when it's not main
- then, we concat some bash commands where we:
  - get the content of the `deploy_output.txt` file
  - extract the content of "Website draft URL: " entirely using grep;
  - split the line into fields delimited by spaces `-d` and then select the fourth field `-f4`, which is where the URL is expected to be based on your output sample
  - save the result of this operation on a variable called `NETLIFY_PREVIEW_URL`
- finally, we say to GitHub Actions that this step will have an output called `NETLIFY_PREVIEW_URL` with its value

To test if it's working, we can simply add another step after this:

**.github/workflows/deploy.yml**

```yaml
# ... rest of the file ...

- name: DEBUG=WILL BE REMOVED
  run: echo ${{ steps.url_preview.outputs.NETLIFY_PREVIEW_URL }}

```

![Print preview URL](https://cdn.sanity.io/images/gc3hakk3/production/8324b41e493c51aee5ce0e116fa66bd83c904c16-1626x184.png?w=1300&h=147&auto=format)

#### Posting URL preview as PR comment

Now we have access to the URL, we can use it to create a PR comment.

Here it'll be raw and dirty; feel free to enhance that in a script, a more fancy message, update an existing comment, etc.

To achieve that, I'm going to use a GitHub action called [github-script](https://github.com/actions/github-script).

In case you don't know, this action gives us access to Github's REST API, the runner context, etc., and lets us script with JavaScript syntax without having to install anything.

The first step is to give our workflow permission to write on pull requests:

**.github/workflows/deploy.yml**

```yaml
name: Deploy website

on: [push]

env:
  BRANCH_NAME: ${{ github.ref_name }}

permissions:
  pull-requests: write

# ... rest ...
```

Then, as the last step, we can add the following code:

**.github/workflows/deploy.yml**

```yaml
# ... rest ...

- name: Comment URL Preview on PR
  uses: actions/github-script@v7
  if: ${{ env.BRANCH_NAME != 'main' }}
  env:
    NETLIFY_PREVIEW_URL: ${{ steps.url_preview.outputs.NETLIFY_PREVIEW_URL }}
  with:
    script: |
      async function comment(){
        const result = await github.rest.repos.listPullRequestsAssociatedWithCommit({
          owner: context.repo.owner,
          repo: context.repo.repo,
          commit_sha: context.sha,
        })

        const issueNumber = result.data[0].number

        if(issueNumber){
          await github.rest.issues.createComment({
            issue_number: issueNumber,
            owner: context.repo.owner,
            repo: context.repo.repo,
            body: 'Preview URL: ' + process.env.NETLIFY_PREVIEW_URL
          })
        }else{
          console.log('No PR found for commit ' + context.sha)
        }
      }

      comment()
```

Breaking it down:

- on line 5, we define we only want to run this step on branches different than the main branch
- on line 7, we save the URL preview into an environment variable
- on line 9, we add a javascript script where:
  - on lines 11 to 15, we use GitHub's SDK to fetch the pull requests with the current commit hash
  - on line 17, we save this information;
  - we save this information.
  - if there's an issue number (PR number), we create a comment with the text 'Preview URL'.

By doing this, if the PR number is found, a comment will be made by the GitHub Bot:

![PR comment](https://cdn.sanity.io/images/gc3hakk3/production/2d89888414b318b146be6732c1d85442de267fca-1666x376.png?rect=0,1,1666,375&w=1300&h=293&auto=format)

And now, we finally have a minimal DX, and we can rest 😅.

### Monorepo case

In case you're using a monorepo, there's just one thing you need to tweak, in the deploy command, you have to add a flag called `--filter` with the project name:

**.github/workflows/deploy.yml**

```diff
#... rest of file

- name: Deploy to Netlify
  id: netlify_deploy
  run: |
    prod_flag=""
    if [ "$BRANCH_NAME" = "main" ]; then prod_flag="--prod"; fi
    netlify deploy \
      --dir dist \
      --site ${{ secrets.NETLIFY_SITE_ID }} \
      --auth ${{ secrets.NETLIFY_API_TOKEN }} \
+    --filter <my-project-name> \
      $prod_flag \
      > deploy_output.txt
```

---

## Why not...

### ... use the official action from Netlify?

If you encountered the [official Netlify's Github Action that provides the CLI](https://github.com/netlify/actions/tree/375963b92b795c7b979927c580dd6f2a65ebcf28/cli) out of the box, you might notice that the last time it was updated (at least when I'm writing this post was a year ago).

I tried, but I got some version conflict problems. It seems I was trying to use an argument that wasn't present in the CLI version of the step.

### .... use the "action-netlify-deploy"?

[This is an open-source action](https://github.com/jsmrcaga/action-netlify-deploy) that abstracts most of the setup we've made here.

I started using it, but I encountered an issue where [it didn't output correctly the Preview URL](https://github.com/jsmrcaga/action-netlify-deploy/issues/57). So I thought: _"If I need to use a third-party action that doesn't do what I need, then I can do it by myself"_.

---

## Conclusion

There is a trade-off between leaving the control of processes with the platform and taking the reins of your deployment. It's up to you to decide what is more worthwhile.

I hope with this guide, you at least can have another option to make the decision that fits you best.

Cheers.

---

## References

- [Netlify CLI](https://docs.netlify.com/cli/get-started/)
- [Netlify Pricing](https://www.netlify.com/pricing/)
- [Netlify deploy preview](https://docs.netlify.com/site-deploys/deploy-previews/)
- [Demo repository](https://github.com/raulmelo-articles/netlify-deploy-gha)
- [actions/github-scripts](https://github.com/actions/github-script)
- [jsmrcaga/action-netlify-deploy](https://github.com/jsmrcaga/action-netlify-deploy)