> ## 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.

# Three ways to manage a website fleet

> Efficient multi-website management using Source Operations on Platform.sh. Automate updates, builds, and deployments for a large number of websites with ease.

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: ["crell"], date: "2019-10-11", image: "/images/posts/engineering/three-ways-to-manage-a-fleet/Blog_Fleet.webp" }} />

As hinted in the [previous post](https://platform.sh/blog/automating-updates-source-operations/), one of the potential use cases for
[Source Operations](https://docs.platform.sh/configuration/app/source-operations.md) is managing and updating a fleet of
sites—a common situation, especially for organizations that have large numbers of sites running identical or nearly
identical code, but in their own projects. Traditional approaches to fleet management involve *pushing* updates to an
array of sites. What Source Operations enables is the ability to have sites *"pull"* updates — en masse,
individually, or as a subset — and own the process of managing the site from the receiving end rather than the
sending end.

With a pull-based approach, of course, it's still useful to have a way to automate updating a large set of sites at once,
building a preview environment, and subsequently merging them. (Just because you *can* deploy updates directly to
production without review doesn't mean you *should.*) Because we provide a robust set of primitives, there are a number
of ways you could build on top of them to create a customized, personalized workflow for
[managing multiple sites](https://platform.sh/blog/managing-multiple-website/).

In this post, we'll look at three ways to build a fleet management workflow on Platform.sh.

In each case, we'll assume that every project has the following source operation defined:

```yaml {filename=".platform.app.yaml"} theme={null}
source:
  operations:
    upstream-update:
      command: |
        set -e
        git remote add upstream $UPSTREAM_REMOTE
        git fetch --all
        git merge upstream/master
```

When triggered, it will merge in any changes from an upstream
<a href="https://docs.platform.sh/development/private-repository.html">Git repository</a> defined in an environment
variable and redeploy that code on that branch. Note that this is just an example script that lacks good error handling,
but is easier to explain for now. There are also, of course, ample other ways to handle a site updating itself, such as
pulling new packages from a private language repository on demand.

## Fully automatic scheduled updates with cron

Because a source operation can be triggered from the CLI tool, it can also be triggered from cron.

First, [install the CLI and add an API key configured](https://docs.platform.sh/gettingstarted/cli/api-tokens.html) to
make it available from the application container. Then, add the following cron job in the `.platform.app.yaml` of the
upstream repository (which will, therefore, be included in all of its clones):

```yaml {filename=".platform.app.yaml"} theme={null}
crons:
    update:
        spec: '0 0 * * *'
        cmd: |
            if [ "$PLATFORM_BRANCH" = upstream-update ]; then
                platform environment:sync code data --no-wait --yes
                platform source-operation:run upstream-update --no-wait --yes
        	fi
```

Now, at about midnight UTC, a cron job will run on each branch, but will be a no-op on most. However, on the branch
named `upstream-update` it will first sync both code and data from the parent branch (presumably `master`) to get the
latest updates, then run a code update on itself.

As a result, the `upstream-update` environment will always have the latest production code and data (as of midnight)
*plus* the latest upstream dependency updates. *You now have an environment that self-updates and previews the resulting
changes.* Whoever manages each individual site can then do a visual review of that branch and, when happy with it, just
click "Merge" from the Management Console in their browser.

Keep your site up to date with just a few mouse clicks.

## Triggering Source Operations updates on demand with scripting

If you'd rather only trigger updates when you actively decide to, rather than nightly, the Platform.sh CLI is highly
scriptable. At the most basic end, one could whip up a small bash script like so:

```bash {filename="Terminal"} theme={null}
platform environment:sync code data -p abc123 -e upstream-update --no-wait --yes
platform source-operation:run upstream-update  -p abc123 -e upstream-update --no-wait --yes
platform environment:sync code data -p def456 -e upstream-update --no-wait --yes
platform source-operation:run upstream-update  -p def456 -e upstream-update --no-wait --yes
```

That code would certainly work to trigger a source operation update across a number of sites, but it's a bit clunky.

Fortunately, the CLI has a helpful `multi` command that can internalize that task for you. Instead of the above, you can
do:

```bash {filename="Terminal"} theme={null}
platform multi --projects=abc123,def456  --continue 'environment:sync code data -e upstream-update --no-wait --yes'
platform multi --projects=abc123,def456  --continue 'source-operation:run upstream-update -e upstream-update --no-wait --yes'
```

And with just those two lines, the CLI will trigger a Sync of both projects' `upstream-update` branches, then queue up
the source operation behind them. For more projects, just list more project IDs in the list.

As with the cron approach, site owners can then click Merge in the UI at their leisure. Alternatively, the CLI can
trigger the merge, too:

```bash {filename="Terminal"} theme={null}
platform multi --projects=abc123,def456 --continue 'merge -e upstream-update --no-wait --yes'
```

And every project will merge `upstream-update` to master, which triggers a new build-and-deploy for production.

The list of project IDs can be comma or whitespace delimited, which means you could also read them from a file, like so:

```bash {filename="Terminal"} theme={null}
platform multi --projects="$(< projects.txt)" --continue 'merge -e upstream-update --no-wait --yes'
```

Because the CLI is scriptable, you could also wrap more complex behavior into a bash script, Python script, PHP script,
or whatever else to build the commands dynamically. Such a script is left as an exercise for the reader.

## Creating a GUI to manage your website fleet with Source Operations

Both of the above methods will scale to a few dozen sites in a fleet. When you're talking about hundreds or even
thousands of sites, though, you may want a more robust solution. Past a certain scale, you want a nice GUI that lets you
mass-manage sites.

Everything the Platform.sh CLI or Management Console do is just a combination of Git commands and API calls. Those
[APIs are documented](https://api.platform.sh/), and of course Git is, well, Git. That means one could build a custom
application to issue calls against that API however and whenever desired. Like, say, from your own web application
(which can itself be hosted on Platform.sh).

We have built a proof of concept of such an application. The application itself is very rudimentary and still lacks
important features like user management (BYO authentication), but it's enough to demonstrate the basic operations you
can do. It's built with Symfony and the EasyAdminBundle, which provides the CRUD UI.

<img src="https://mintcdn.com/upsun-c9761871/ZBMwngApLEEeuab_/images/posts/engineering/three-ways-to-manage-a-fleet/project-list.webp?fit=max&auto=format&n=ZBMwngApLEEeuab_&q=85&s=945a562a4e2887ead5246825e21c1273" alt="Project list" width="1058" height="518" data-path="images/posts/engineering/three-ways-to-manage-a-fleet/project-list.webp" />

The dashboard lists multiple projects owned by the same Platform.sh user. It's largely an automated list of Doctrine
objects. (Doctrine is the standard ORM for Symfony.) Projects can be added through this application as well:

<img src="https://mintcdn.com/upsun-c9761871/ZBMwngApLEEeuab_/images/posts/engineering/three-ways-to-manage-a-fleet/project-new.webp?fit=max&auto=format&n=ZBMwngApLEEeuab_&q=85&s=d0cc116aaa0fea4e852bd8f42d1cd76c" alt="New Project Screen" width="1058" height="430" data-path="images/posts/engineering/three-ways-to-manage-a-fleet/project-new.webp" />

Projects are created from an "Archetype," which is just a simple record of an upstream Git repository and some
configuration data, like the name of the environment to use for updates. It can also be placed in any Platform.sh
region. More settings could easily be added.

On `Save`, the application issues an API call to Platform.sh to create a new project with the specified criteria. The
project is then initialized with a clone of the Archetype Git repository.

Once a project is created and tracked by the application, it can issue API calls against one or more projects in a
batched set.

<img src="https://mintcdn.com/upsun-c9761871/ZBMwngApLEEeuab_/images/posts/engineering/three-ways-to-manage-a-fleet/project-list-selected.webp?fit=max&auto=format&n=ZBMwngApLEEeuab_&q=85&s=452708d8bcbdc15b0422a15fd6990624" alt="Selected projects and available commands" width="1058" height="511" data-path="images/posts/engineering/three-ways-to-manage-a-fleet/project-list-selected.webp" />

* "Backup" is just an API call to trigger a backup on the master environment.
* "Update" triggers a sync on the update branch and then runs the source operation.
* "Merge update" triggers a simple merge of the update branch to master.
* "Delete" deletes the projects from the dashboard, *and* from Platform.sh.

This demo application is not production usable in its current state, of course, but demonstrates the sort of custom
mass-management dashboard one could build. In fact, some of our customers have already built White Label SaaS tools to
do such management themselves, including managing billing and accounts for their own customers. They didn't even tell us
they were doing it! (That's a good sign that our APIs are where we want them.)

Naturally, there are ample other ways to wrap a user interface around the Platform.sh API, depending on your specific
use case.

## Platform.sh is designed to give you flexibility for your fleet workflow

Regardless of how many sites you have and how you want to manage them, there are options available. Our goal is to let
you build the perfect SaaS, fleet deployment, or other management toolchain for your organization. Between the
Platform.sh REST API and our new [Source Operations feature](https://platform.sh/blog/automating-updates-source-operations/), the
components are there to let you build what you want, how you want, as you want.
