> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Synchronize your air-gapped GitLab with Upsun

> Configure GitLab CI/CD to deploy to Upsun from private air-gapped GitLab instances using push-based workflows and API automation


export const PostMeta = ({data = {}}) => {
  const {author, date, image} = data;
  const authors = Array.isArray(author) ? author : author ? [author] : [];
  const resolveAuthor = slug => {
    const entry = AUTHOR_MAP[slug] || ({});
    const name = entry.name || slug;
    const github = entry.github || null;
    const linkedin = entry.linkedin || null;
    const url = github ? `https://github.com/${github}` : linkedin || null;
    const avatarUrl = github ? `https://github.com/${github}.png?size=64` : null;
    return {
      name,
      url,
      avatarUrl
    };
  };
  const formattedDate = date ? new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  }) : null;
  if (!image && authors.length === 0 && !formattedDate) return null;
  const AUTHOR_MAP = {
    "aaron-collier": {
      "name": "Aaron Collier"
    },
    "aaron-dudenhofer": {
      "name": "Aaron Dudenhofer"
    },
    "aaron-porter": {
      "name": "Aaron Porter"
    },
    "adriaan-odendaal": {
      "name": "Adriaan Odendaal"
    },
    "ajmal": {
      "name": "Ajmal Siddiqui"
    },
    "akalipetis": {
      "name": "Antonis Kalipetis"
    },
    "alexander-varwijk": {
      "name": "Alexander Varwijk"
    },
    "alicia-bevilacqua": {
      "name": "Alicia Bevilacqua"
    },
    "amelie-deguerry": {
      "name": "Amelie Deguerry"
    },
    "anacidre": {
      "name": "Ana Cidre",
      "linkedin": "https://www.linkedin.com/in/ana-cidre"
    },
    "andoni": {
      "name": "Andoni Auzmendi"
    },
    "andrei-taranu": {
      "name": "Andrei (Alex) Taranu",
      "linkedin": "https://www.linkedin.com/in/andrei-alex-taranu/"
    },
    "andrew-baxter": {
      "name": "Andrew Baxter"
    },
    "andrew-melck": {
      "name": "Andrew Melck"
    },
    "antoine-crochet-damais": {
      "name": "Antoine Crochet Damais"
    },
    "augustin-delaporte": {
      "name": "Augustin Delaporte",
      "linkedin": "https://www.linkedin.com/in/augustindelaporte/"
    },
    "branislav-bujisic": {
      "name": "Branislav Bujisic"
    },
    "carl-smith": {
      "name": "Carl Smith"
    },
    "caroline-leroy": {
      "name": "Caroline Leroy"
    },
    "cati-mayer": {
      "name": "Cati Mayer"
    },
    "catplat": {
      "name": "C Trinkwon"
    },
    "ceelolulu": {
      "name": "Celeste van der Watt"
    },
    "chadwcarlson": {
      "name": "Chad Carlson",
      "github": "chadwcarlson",
      "linkedin": "https://www.linkedin.com/in/chadwcarlson"
    },
    "chris-ward": {
      "name": "Chris Ward"
    },
    "chris-yates": {
      "name": "Chris Yates"
    },
    "christian-sieber": {
      "name": "Christian Sieber"
    },
    "christopher-lockheardt": {
      "name": "Christopher Lockheardt"
    },
    "christopher-skene": {
      "name": "Christopher Skene"
    },
    "chuck-morgan": {
      "name": "Chuck Morgan"
    },
    "corey-dockendorf": {
      "name": "Corey Dockendorf"
    },
    "crell": {
      "name": "Crell"
    },
    "damz": {
      "name": "Damz"
    },
    "dan-morrison": {
      "name": "Dan Morrison"
    },
    "davidbonachera": {
      "name": "David Bonachera",
      "github": "davidbonachera",
      "linkedin": "https://www.linkedin.com/in/davidbonachera"
    },
    "dereliahmet1": {
      "name": "Ahmet Faruk Dereli"
    },
    "devicezero": {
      "name": "Jonas Kröger",
      "github": "devicezero",
      "linkedin": "https://www.linkedin.com/in/jonaskroeger/"
    },
    "doug-goldberg": {
      "name": "Doug Goldberg"
    },
    "duncan-naves": {
      "name": "Duncan Naves",
      "github": "duncannaves",
      "linkedin": "https://www.linkedin.com/in/duncan-naves-a94423aa"
    },
    "erika-bustamante": {
      "name": "Erika Bustamante"
    },
    "fabpot": {
      "name": "Fabien Potencier"
    },
    "flovntp": {
      "name": "Florent Huck",
      "github": "flovntp",
      "linkedin": "https://www.linkedin.com/in/florenthuck"
    },
    "fred-plais": {
      "name": "Fred Plais"
    },
    "gauthier-garnier": {
      "name": "Gauthier Garnier"
    },
    "gilzow": {
      "name": "Paul Gilzow"
    },
    "gmoigneu": {
      "name": "Guillaume Moigneu",
      "github": "gmoigneu",
      "linkedin": "https://www.linkedin.com/in/guillaumemoigneu/"
    },
    "gregqualls": {
      "name": "Greg Qualls"
    },
    "guguss": {
      "name": "Augustin Delaporte"
    },
    "haylee-millar": {
      "name": "Haylee Millar"
    },
    "ivana-kotur": {
      "name": "Ivana Kotur"
    },
    "jackrabbithanna": {
      "name": "Mark Hanna"
    },
    "jared-wright": {
      "name": "Jared Wright",
      "github": "jww-sh",
      "linkedin": "https://www.linkedin.com/in/jaredwaynewright"
    },
    "jessica-orozco": {
      "name": "Jessica Orozco"
    },
    "joey-stanford": {
      "name": "Joey Stanford"
    },
    "john-grubb": {
      "name": "John Grubb"
    },
    "jonas-kruger": {
      "name": "Jonas Kruger"
    },
    "kathryn-frazer": {
      "name": "Kathryn Frazer"
    },
    "kemiojo": {
      "name": "Kemi Elizabeth Ojogbede"
    },
    "kieronsambrook-smith": {
      "name": "Kieronsambrook Smith"
    },
    "laurent-arnoud": {
      "name": "Laurent Arnoud"
    },
    "letoya-boyne": {
      "name": "Letoya Boyne"
    },
    "lolautruche": {
      "name": "Jérôme Vieilledent"
    },
    "lyly-lepinay": {
      "name": "Lyly Lepinay"
    },
    "manauwar-alam": {
      "name": "Manauwar Alam"
    },
    "marc-antoine-porri": {
      "name": "Marc Antoine Porri"
    },
    "maria-antinkaapo": {
      "name": "Maria Antinkaapo"
    },
    "maria-de-anton": {
      "name": "Maria De Anton"
    },
    "mark-dorison": {
      "name": "Mark Dorison"
    },
    "markus-hausammann": {
      "name": "Markus Hausammann"
    },
    "mary-thomas": {
      "name": "Mary Thomas"
    },
    "mathias-bolt-lesniak": {
      "name": "Mathias Bolt Lesniak"
    },
    "mathieu-strauch": {
      "name": "Mathieu Strauch"
    },
    "matthias-van-woensel": {
      "name": "Matthias Van Woensel",
      "linkedin": "https://www.linkedin.com/in/matthias-van-woensel-267a069"
    },
    "michael-sharp": {
      "name": "Michael Sharp"
    },
    "mupsi": {
      "name": "Marine Gandy"
    },
    "natalie-harper": {
      "name": "Natalie Harper"
    },
    "ngommenginger": {
      "name": "Nicolas Gommenginger",
      "linkedin": "https://www.linkedin.com/in/nicolas-gommenginger"
    },
    "nicholas-bennison": {
      "name": "Nicholas Bennison"
    },
    "nicholas-vahalik": {
      "name": "Nicholas Vahalik"
    },
    "nick-hardiman": {
      "name": "Nick Hardiman"
    },
    "nickanderegg": {
      "name": "Nickanderegg"
    },
    "nicolas-grekas": {
      "name": "Nicolas Grekas",
      "github": "nicolas-grekas",
      "linkedin": "https://www.linkedin.com/in/nicolasgrekas/"
    },
    "niti-malwade": {
      "name": "Niti Malwade"
    },
    "opensocialteam": {
      "name": "Opensocialteam"
    },
    "ori-pekelman": {
      "name": "Ori Pekelman"
    },
    "otavio-santana": {
      "name": "Otavio Santana"
    },
    "palwandi": {
      "name": "Pawan Alwandi",
      "github": "pawpy",
      "linkedin": "https://www.linkedin.com/in/pawanalwandi"
    },
    "patrick-boest": {
      "name": "Patrick Boest"
    },
    "patrick-dawkins": {
      "name": "Patrick Dawkins",
      "github": "pjcdawkins",
      "linkedin": "https://www.linkedin.com/in/patrickdawkins"
    },
    "patrick-klima": {
      "name": "Patrick Klima"
    },
    "pjcdawkins": {
      "name": "Pjcdawkins"
    },
    "prineet-kaurbhurji": {
      "name": "Prineet Kaurbhurji"
    },
    "quentin-sinig": {
      "name": "Quentin Sinig"
    },
    "ralt": {
      "name": "Florian Margaine",
      "github": "ralt",
      "linkedin": "https://www.linkedin.com/in/florian-margaine-43971136"
    },
    "ramanathanramakrishnamurthy": {
      "name": "Ramanathanramakrishnamurthy"
    },
    "remi-lejeune": {
      "name": "Rémi Lejeune"
    },
    "ribel": {
      "name": "Taras Kruts"
    },
    "robert-douglass": {
      "name": "Robert Douglass"
    },
    "rudy-weber": {
      "name": "Rudy Weber"
    },
    "ryan-hicks": {
      "name": "Ryan Hicks"
    },
    "sabri-helal": {
      "name": "Sabri Helal"
    },
    "savannah-bergeron": {
      "name": "Savannah Bergeron"
    },
    "shannon-vettes": {
      "name": "Shannon Vettes"
    },
    "shawn-ogasawara": {
      "name": "Shawn Ogasawara",
      "linkedin": "https://www.linkedin.com/in/shawn-ogasawara-83a9a0/"
    },
    "shawna-spoor": {
      "name": "Shawna Spoor"
    },
    "shedrack-akintayo": {
      "name": "Shedrack Akintayo"
    },
    "simon-ruggier": {
      "name": "Simon Ruggier"
    },
    "sophie-van-der-kindere": {
      "name": "Sophie Van Der Kindere"
    },
    "stefanos-thampis": {
      "name": "Stefanos Thampis"
    },
    "stephen-weinberg": {
      "name": "Stephen Weinberg"
    },
    "sukhman-virk": {
      "name": "Sukhman Virk"
    },
    "sumaira-nazir": {
      "name": "Sumaira Nazir"
    },
    "sumer": {
      "name": "Sümer Cip"
    },
    "syed-raza": {
      "name": "Syed Raza"
    },
    "tamara-bacchia": {
      "name": "Tamara Bacchia"
    },
    "tara-arnold": {
      "name": "Tara Arnold"
    },
    "theosakamg": {
      "name": "Mickael Gaillard",
      "github": "theosakamg"
    },
    "thomasdiluccio": {
      "name": "Thomas di Luccio"
    },
    "tim-anderson": {
      "name": "Tim Anderson"
    },
    "tom-helmer-hansen": {
      "name": "Tom Helmer Hansen"
    },
    "tylermills": {
      "name": "Tyler Mills"
    },
    "upsun": {
      "name": "Upsun"
    },
    "veronika-tolkachova": {
      "name": "Veronika Tolkachova",
      "linkedin": "https://www.linkedin.com/in/veronika-tolkachova-169167a2"
    },
    "vince-parker": {
      "name": "Vince Parker"
    },
    "vinnie-russo": {
      "name": "Vincenzo Russo"
    },
    "vrobert78": {
      "name": "Vincent Robert",
      "github": "vrobert78",
      "linkedin": "https://www.linkedin.com/in/vincent-robert-498a883"
    },
    "yuriy-babenko": {
      "name": "Yuriy Babenko"
    },
    "yuriy-gerasimov": {
      "name": "Yuriy Gerasimov"
    }
  };
  return <div className="post-meta">
      {(authors.length > 0 || formattedDate) && <div className="post-meta-info">
          {authors.length > 0 && <div className="post-meta-authors">
              {authors.map(slug => {
    const {name, url, avatarUrl} = resolveAuthor(slug);
    const inner = <>
                    {avatarUrl && <img src={avatarUrl} alt={name} className="post-meta-avatar" />}
                    <span className="post-meta-author-name">{name}</span>
                  </>;
    return url ? <a key={slug} href={url} target="_blank" rel="noopener noreferrer" className="post-meta-author">
                    {inner}
                  </a> : <span key={slug} className="post-meta-author">{inner}</span>;
  })}
            </div>}
          {authors.length > 0 && formattedDate && <span className="post-meta-separator" aria-hidden="true">·</span>}
          {formattedDate && <span className="post-meta-date">{formattedDate}</span>}
        </div>}
      {image && <img src={image} alt="" className="post-meta-image" aria-hidden="true" />}
    </div>;
};

<PostMeta data={{ author: ["gmoigneu"], date: "2025-07-21T02:00:00+00:00", image: "/images/posts/hands-on/gitlab-push-solution/manualjob.webp" }} />

When your GitLab instance isn't accessible from the internet, you can't use Upsun's standard GitLab integration. Instead, you can create a GitLab CI/CD pipeline that pushes code to Upsun and manages environments through API calls.

This approach gives you full control over your deployment process while maintaining the security of your private GitLab instance.

## Why the standard GitLab integration won't work

The standard Upsun GitLab integration relies on a webhook-based architecture:

1. **GitLab sends webhooks** - When you push code, create branches, or open Merge Requests, GitLab sends HTTP webhooks to Upsun's servers
2. **Upsun pulls your code** - Upon receiving the webhook, Upsun attempts to connect back to your GitLab instance to pull the repository
3. **The connection fails** - Since your GitLab instance isn't accessible from the internet, Upsun can't reach it to pull the code

This webhook-pull pattern requires bidirectional connectivity between GitLab and Upsun. When your GitLab instance sits behind a firewall or on a private network, Upsun's servers can't establish the return connection needed to fetch your code.

### The push-based solution

Instead of relying on Upsun pulling from GitLab, you can implement a push-based workflow where your GitLab CI/CD pipeline actively pushes code to Upsun. This approach works because:

* Your GitLab runners can reach external services (including Upsun)
* No inbound connections to your GitLab instance are required
* You maintain full control over when and how deployments occur
* Your GitLab instance remains completely isolated from the internet

This CI/CD pipeline approach effectively reverses the flow: instead of Upsun pulling from GitLab, your pipeline pushes to Upsun, bypassing the connectivity requirements of the standard integration.

**The pipeline will take care of pushing to the `main` production environment when the branch is updated and will create preview environments whenever a new merge request is created.**

## Prerequisites

Before setting up your pipeline, you'll need:

* A GitLab runner with internet access to reach Upsun
* An Upsun project
* An [Upsun API token](https://docs.upsun.com/administration/cli/api-tokens.html#2-create-an-api-token) for environment management
* SSH keys configured on Upsun for Git operations

## Setting up authentication

### Generate an SSH key pair

Create a dedicated SSH key for your GitLab CI/CD pipeline:

```bash theme={null}
ssh-keygen -t ed25519 -C "gitlab-ci@example.com" -f upsun_deploy_key
```

Grab the content of the public key:

```bash theme={null}
cat upsun_deploy_key.pub | pbcopy
```

In the [Upsun interface](https://console.upsun.com/), create a new SSH key for your user:

<img src="https://mintcdn.com/upsun-c9761871/tziXiwEbbKjwbX3l/images/posts/hands-on/gitlab-push-solution/ssh-key.gif?s=11bad07349166dcd75d6131836696f72" alt="Add SSH Key" width="3022" height="1892" data-path="images/posts/hands-on/gitlab-push-solution/ssh-key.gif" />

### Configure GitLab CI/CD variables

Add these variables to your GitLab project's CI/CD settings:

* `UPSUN_PROJECT_ID`: Your Upsun project ID (e.g., `abcdefgh1234567`)
* `UPSUN_API_TOKEN`: Your Upsun API token (masked variable)
* `UPSUN_SSH_PRIVATE_KEY`: Contents of your private SSH key (It unfortunately can't be a masked variable due to the key format)
* `UPSUN_GIT_REMOTE`: Your Upsun Git remote URL
* `UPSUN_REGION`: The Upsun region the project is hosted on (`us-3.platform.sh`, `fr-1.platform.sh`, etc.)

<img src="https://mintcdn.com/upsun-c9761871/tziXiwEbbKjwbX3l/images/posts/hands-on/gitlab-push-solution/gitlab-variables.webp?fit=max&auto=format&n=tziXiwEbbKjwbX3l&q=85&s=a45df7533df596b8f3d6f953b8e2acf5" alt="Gitlab Variables" width="2298" height="1206" data-path="images/posts/hands-on/gitlab-push-solution/gitlab-variables.webp" />

## Creating the GitLab CI/CD pipeline

<Note>
  The full `.gitlab-ci.yml` file [can be found in our GitHub snippets repository](https://github.com/upsun/snippets/blob/main/examples/gitlab-ci/push-solution.yaml).
</Note>

Create a `.gitlab-ci.yml` file in your repository root.

Start by creating two scripts that will be referenced in the different jobs.

The first one sets up your private SSH key on the container and whitelists the Upsun git endpoint:

```yaml {filename=".gitlab-ci.yml"} theme={null}
.setup_ssh: &setup_ssh
  - echo "Setup SSH"
  - mkdir -p ~/.ssh
  - echo "$UPSUN_SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
  - chmod 600 ~/.ssh/id_rsa
  - ssh-keyscan -H git.$UPSUN_REGION >> ~/.ssh/known_hosts
```

The second is a function that will be called when we need to exchange our API token for a valid access token to actually query the API:

```yaml {filename=".gitlab-ci.yml"} theme={null}
.access_token: &access_token
  - |
    export UPSUN_ACCESS_TOKEN=$(curl -u platform-api-user: \
      -d "grant_type=api_token&api_token=$UPSUN_API_TOKEN" \
      https://auth.upsun.com/oauth2/token | jq -r .access_token)
```

### Deploying to production

Once done, you can create the first job, deploying the main branch to Upsun:

```yaml {filename=".gitlab-ci.yml"} theme={null}
# Deploy to production
deploy_production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache curl jq git openssh
    - *setup_ssh
  script:
    - |
      git config --global user.email "gitlab-ci@example.com"
      git config --global user.name "GitLab CI"
      git remote add upsun $UPSUN_GIT_REMOTE || git remote set-url upsun $UPSUN_GIT_REMOTE
      git push upsun main
  only:
    - main  # Adjust based on your default branch
```

The job is straightforward: It sets up our Git identity, sets the remote and pushes to the main branch. Since the `main` environment on Upsun is always enabled, no additional checks are required. This job will be triggered any time something happens on the main branch (merge, commit, etc.).

### Deploying preview environments for Merge Requests

For preview environments based on Merge Requests, the job will follow the same logic with some extra steps to enable the environment:

```yaml {filename=".gitlab-ci.yml"} theme={null}
# Deploy on push to branches and new Merge Requests
deploy_to_upsun:
  stage: deploy
  image: alpine:latest
  before_script:
    [..]
    - *access_token
  script:
    - |
      [...]
      
      echo "Checkout branch and push to Upsun"
      git checkout -B $CI_COMMIT_REF_NAME
      git push upsun $CI_COMMIT_REF_NAME

      echo "Activate environment"
      echo "UPSUN_ACCESS_TOKEN: $UPSUN_ACCESS_TOKEN"
      curl -s -X POST \
        -H "Authorization: Bearer $UPSUN_ACCESS_TOKEN" \
        -H "Content-Type: application/json" \
        "https://api.upsun.com/projects/$UPSUN_PROJECT_ID/environments/main/activate"

  only:
    - merge_requests
  except:
    - main  # Adjust based on your default branch
```

The script first trades our `API_TOKEN` for an `access_token`, checks out the correct branch and then pushes it. The last step is to call the Upsun API to activate the environment.

With the above job in place, every new Merge Request created on your GitLab will trigger an environment creation on the Upsun side. Please note that the API call might throw a graceful error if the environment is already activated.

<Note>
  While the configuration triggers this job on a new Merge Request, you can change this to follow branches by switching the `only:` flag to `branches` instead. This can be done if your workflow does not rely on Merge Requests.
</Note>

### Cleaning up unused environments

In order to not be running environments and resources for nothing, you can add a new job to clean up environments when a Merge Request is closed or merged.

GitLab does not allow the CI script to detect exactly what happened on the Merge Request. It can only detect that *something* happened. That's why the script uses a `manual` flag to trigger the job. A more robust solution would be to set up webhooks that call a script to handle the cleanup.

The cleanup script includes more actions as we need to complete the following tasks:

* Delete the Upsun remote branch
* Deactivate the Upsun environment
* Delete the Upsun environment

```yaml {filename=".gitlab-ci.yml"} theme={null}
# Cleanup environments when branches are deleted or MRs are merged
cleanup_environment:
  stage: cleanup
  image: alpine:latest
  before_script:
    [...]
    - *access_token
  script:
    - |
      # Get the source branch name from the merge request
      BRANCH_NAME="${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}"
      
      [...]
      
      # Delete the branch from Upsun remote
      git push upsun --delete "$BRANCH_NAME" || echo "Branch already deleted from remote"
      
      # Deactivate the environment via API
      curl -s -X POST \
        -H "Authorization: Bearer $UPSUN_ACCESS_TOKEN" \
        -H "Content-Type: application/json" \
        "https://api.upsun.com/projects/$UPSUN_PROJECT_ID/environments/$BRANCH_NAME/deactivate" || echo "Environment deactivation failed"
      
      # Optionally delete the environment completely
      curl -s -X DELETE \
        -H "Authorization: Bearer $UPSUN_ACCESS_TOKEN" \
        "https://api.upsun.com/projects/$UPSUN_PROJECT_ID/environments/$BRANCH_NAME" || echo "Environment deletion failed"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: manual
  allow_failure: true
```

You can trigger this script any time by going into your GitLab pipelines view:

<img src="https://mintcdn.com/upsun-c9761871/tziXiwEbbKjwbX3l/images/posts/hands-on/gitlab-push-solution/manualjob.webp?fit=max&auto=format&n=tziXiwEbbKjwbX3l&q=85&s=88616a1cc846570c2267084470edc8a8" alt="Trigger cleanup" width="1408" height="690" data-path="images/posts/hands-on/gitlab-push-solution/manualjob.webp" />

### Deploying the script

Now that you have created the whole [.gitlab-ci.yml](https://github.com/upsun/snippets/blob/main/examples/gitlab-ci/push-solution.yaml) file, add it to your repository:

```bash theme={null}
git add .gitlab-ci.yml
git commit -m "Add GitLab CI configuration"
git push origin main
```

Congratulations! That push should trigger your first pipeline.

<img src="https://mintcdn.com/upsun-c9761871/tziXiwEbbKjwbX3l/images/posts/hands-on/gitlab-push-solution/firstpipeline.webp?fit=max&auto=format&n=tziXiwEbbKjwbX3l&q=85&s=52c2b1e9061f30c44af82ecb93abf006" alt="First pipeline" width="2456" height="1246" data-path="images/posts/hands-on/gitlab-push-solution/firstpipeline.webp" />

## Branch name conflicts

If your branch names contain special characters, Upsun recommends converting them to safe strings:

```yaml theme={null}
script:
  - SAFE_BRANCH_NAME=$(echo $CI_COMMIT_REF_NAME | sed 's/[^a-zA-Z0-9-]/-/g')
  - git push upsun HEAD:$SAFE_BRANCH_NAME
```

## Security best practices

1. **Rotate API tokens regularly** - As API tokens have no expiration, it is recommended to rotate them periodically
2. **Use protected variables** - Mark sensitive variables as protected in GitLab
3. **Limit runner access** - Use specific runner tags for deployment jobs
4. **Audit deployments** - Enable GitLab's deployment tracking

## Summary

With your GitLab CI/CD pipeline configured, you can now deploy to Upsun from your private air-gapped GitLab instance. This setup provides:

* Automatic deployments on every push
* Preview environments for Merge Requests
* Cleanup of unused environments
* Full control over your deployment process

For more advanced configurations, explore [Upsun's API documentation](https://api.upsun.com/docs) to customize environment settings, manage resources, and integrate with your existing DevOps tools.

***

[Create your Upsun account](https://upsun.com) to get started with GitLab CI/CD deployments today.
