FTP No More: Deploying WordPress sites with GitHub Actions
Tags: git, github, wordpress, cicd
Published: 2023-01-27 13:45:38

<p>Do you still use FileZilla to send files one by one to the server? It is time to change! After reading this post all you will need is to push a file to a GitHub repository and it will be automatically sent to your server.</p>

<h2 class="wp-block-heading">If you are in a hurry... that is fine!</h2>

<p>There is no problem if you are in a hurry. In summary, this is what we are going to do:</p>

<ol><li><a href="#ssh-access">Create an SSH Keys pair to connect to your server</a>;</li>

<li><a href="#how-to-create-a-secret-in-github-actions">Create the <code>DEPLOY_SSH_HOST</code>, <code>DEPLOY_SSH_USER</code>, and <code>DEPLOY_SSH_KEY</code> secrets in GitHub</a>;</li>

<li><a href="#about-exclusions-and-rsync">Create the <code>bin/rsync-excludes.txt</code> file in your repository</a>;</li>

<li><a href="#github-actions">Create the <code>.github/workflows/deploy.yml</code> file in your repository</a>;</li>

<li>Make any changes in the <code>trunk</code> branch.</li>
<!-- /wp:list-item --></ol>

<blockquote class="wp-block-quote"><p>If this post helps you, don't forget to share it!</p>
<!-- /wp:paragraph --></blockquote>

<h2 class="wp-block-heading">What you need</h2>

<p>The list of things you need for this post is quite simple:</p>

<ul><li><strong>SSH Acess to your server</strong>: Available in almost all host providers nowadays;</li>

<li><strong>A GitHub repository</strong>: Free and unlimited, including private ones.</li>
<!-- /wp:list-item --></ul>

<p>Don't forget to back up your files before you continue. If something goes wrong you will have a way to revert it.</p>

<h2 class="wp-block-heading">What the GitHub Action will do</h2>

<p>Basically, when a file in a certain branch is updated, <strong>a GitHub Action will connect to your server via SSH and send the files</strong> using the <code>rsync</code> command.</p>

<figure class="wp-block-image aligncenter size-full is-resized"><a href="https://felipeelia.com.br/wp-content/uploads/2023/01/image-3.png"><img src="https://felipeelia.com.br/wp-content/uploads/2023/01/image-3.png" alt="" class="wp-image-2670" width="585" height="158"/></a><figcaption class="wp-element-caption">The green ✓ means an Action was executed successfully.</figcaption></figure>

<h2 class="wp-block-heading" id="ssh-access">SSH Access</h2>

<p>Your GitHub Action will need to connect to your server via SSH (Secure SHell) to send the files. To make that possible, <strong>we will create a new pair of SSH keys and configure the server to accept it</strong>.</p>

<h3 class="wp-block-heading">1. Create a pair of SSH keys</h3>

<p>The cryptography used by the SSH protocol uses <strong>two keys: a public and a private</strong>. So your GitHub Action can send the files to your server, you will need a new pair of keys. Although possible, I do not recommend using the same key you use on your computer.</p>

<p>The command to create a new key is the following:</p>

<pre class="wp-block-code"><code>ssh-keygen -t ed25519 -C "user@domain"</code></pre>

<ul><li><code>ssh-keygen</code>: The command itself;</li>

<li><code>-t ed25519</code>: The algorithm used in the file creation;</li>

<li><code>-C "user@domain"</code>: It does not need to be a real email. It is only used to differentiate keys, as a comment. Something like <code>deploy@mysite.com</code> is enough.</li>
<!-- /wp:list-item --></ul>

<p>The command will ask:</p>

<ul><li><em>A password</em>: leave it empty to make the process easier;</li>

<li><em>A filename</em>: do not use the default, put something like <code>deploy</code> or the project name. Do not add any extensions.</li>
<!-- /wp:list-item --></ul>

<figure class="wp-block-image aligncenter size-large is-resized"><a href="https://felipeelia.com.br/wp-content/uploads/2023/01/image-5.png"><img src="https://felipeelia.com.br/wp-content/uploads/2023/01/image-5-1024x494.png" alt="" class="wp-image-2692" width="768" height="371"/></a><figcaption class="wp-element-caption">The <code>ssh-keygen</code> command and its return</figcaption></figure>

<h3 class="wp-block-heading">2. Authorize the key to connect to your server</h3>

<p>Each hosting provider handles this in a different way. In services that provide some sort of dashboard, it can be in My Account &gt; SSH or similar.</p>

<p>On Cloudways, for example, the SSH keys are handled per application. In the <em>Access Details</em> section, it is possible to manage the keys under <em>Application Credentials</em>. (Cloudways also offers a <em>Deployment Via Git</em> option that we are not covering here.)</p>

<figure class="wp-block-image aligncenter size-large is-resized"><a href="https://felipeelia.com.br/wp-content/uploads/2023/01/cloudways-ssh.png"><img src="https://felipeelia.com.br/wp-content/uploads/2023/01/cloudways-ssh-1024x477.png" alt="" class="wp-image-2695" width="768" height="358"/></a><figcaption class="wp-element-caption">Cloudways Application Management dashboard</figcaption></figure>

<p>In the image we have arrows pointing to three elements:</p>

<ul><li><em>Public IP</em>: This will be used as the host in our setting. In many cases, it is the website domain.</li>

<li><em>Username</em>: Cloudways has a different <em>username</em> per application. Some hosts use the same username for dashboard and SSH accesses.</li>

<li><em>SSH Keys</em>: This is the place where it is possible to set new keys for this application. There, you can provide the public key (<code>file.pub</code>) of the pair we've generated.</li>
<!-- /wp:list-item --></ul>

<h3 class="wp-block-heading">3. Test the SSH key (Optional)</h3>

<p>You can use your computer to test the access using the new key. For that, all you have to do is call:</p>

<pre class="wp-block-code bash"><code>ssh -i &lt;private-key-filepath&gt; &lt;user&gt;@&lt;server&gt;</code></pre>

<ul><li><code>&lt;private-key-filepath&gt;</code>: The path to the private key file (without<code>.pub</code>) in your computer;</li>

<li><code>&lt;user&gt;@&lt;server&gt;</code>: It can change depending on the provider. For that screenshot from Cloudways, it would be <code>appname_ssh@12.34.567.890</code>.</li>
<!-- /wp:list-item --></ul>

<p>If we would use the key generated in our example, the call would look like this:</p>

<pre class="wp-block-code bash"><code>ssh -i /home/elia/.ssh/exemplopost appname_ssh@12.34.567.890</code></pre>

<p>If you had everything right, you will see the server terminal on your computer.</p>

<h2 class="wp-block-heading" id="github-actions">GitHub Actions</h2>

<p><strong><a href="https://github.com/features/actions" target="_blank" rel="noreferrer noopener">GitHub Actions</a> is GitHub's automation tool.</strong> In summary, GitHub will analyze any <code>.yml</code> file in the <code>.github/workflows</code> folder of your repository and try to execute it every time something happens in your repo.</p>

<p>In our case, we will create a file named <code>.github/workflows/deploy.yml</code>. You can name it as you want&nbsp;but you need to keep the <code>.yml</code> extension and the file location.</p>

<blockquote class="wp-block-quote"><p>ATTENTION: .yml files do not allow <em>tabs</em>, only <em>spaces </em>(2 or 4).</p>
<!-- /wp:paragraph --></blockquote>

<p>Our <code>.github/workflows/deploy.yml</code> file contents will be as follows:</p>

<pre class="wp-block-code yml"><code>name: Deploy

env:
  SSH_USER: $`{{ secrets.DEPLOY_SSH_USER }}`
  SSH_HOST: $`{{ secrets.DEPLOY_SSH_HOST }}`

on:
  push:
    branches:
      - trunk

jobs:
  deploy_cloudways:
    name: Deploy to Cloudways
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Configure SSH
      run: |
        mkdir -p ~/.ssh/
        echo "$SSH_KEY" &gt; ~/.ssh/deploy.key
        chmod 600 ~/.ssh/deploy.key
        cat &gt;&gt;~/.ssh/config &lt;&lt;END
        Host cloudways
          HostName $SSH_HOST
          User $SSH_USER
          IdentityFile ~/.ssh/deploy.key
          StrictHostKeyChecking no
        END
      env:
        SSH_KEY: $`{{ secrets.DEPLOY_SSH_KEY }}`

    - name: Send files
      run: "rsync --delete -avO $`{{ env.RSYNC_FLAGS }}` --exclude-from=$`{{ env.EXCLUDES }}` ./ $`{{ env.SSH_USER }}`@$`{{ env.SSH_HOST }}`:$`{{ env.DESTINATION }}`"
      env:
        RSYNC_FLAGS: '' #--dry-run
        EXCLUDES: bin/rsync-excludes.txt
        SSH_HOST: cloudways
        DESTINATION: "~/public_html/wp-content/"
</code></pre>

<h3 class="wp-block-heading">Workflow name</h3>

<p>The first line of our file simply gives a name to our <em><a href="https://docs.github.com/pt/actions/learn-github-actions/understanding-github-actions#fluxos-de-trabalho" target="_blank" rel="noreferrer noopener">workflow</a></em>. The name is used just for display purposes and does not interfere with the automation functionality.</p>

<pre class="wp-block-code yml"><code>name: Deploy
</code></pre>

<h3 class="wp-block-heading">Secrets for GitHub Action</h3>

<p>The second section of our file configures environment variables to be shared across some <em>steps </em>further up.</p>

<pre class="wp-block-code yml"><code>env:
  SSH_USER: $`{{ secrets.DEPLOY_SSH_USER }}`
  SSH_HOST: $`{{ secrets.DEPLOY_SSH_HOST }}`</code></pre>

<p>As the description of our workflow is made in a file visible to anyone with read access in the repository, GitHub provides a way to create secrets, editable only by administrators of the repo or organization.</p>

<h4 class="wp-block-heading" id="how-to-create-a-secret-in-github-actions">How to create a secret in GitHub Actions</h4>

<p>Let's remember the SSH access command we previously used to test:</p>

<pre class="wp-block-code bash"><code>ssh -i &lt;private-key-filepath&gt; &lt;user&gt;@&lt;server&gt;</code></pre>

<p>With that command in mind, access your repository, and go to Settings &gt; Secrets and Variables &gt; Actions. <strong>We will create three secrets</strong>:</p>

<ul><li><code>DEPLOY_SSH_KEY</code>: The <strong>content</strong> of the private key you created.</li>

<li><code>DEPLOY_SSH_USER</code>: What you used as <code>&lt;user&gt;</code>;</li>

<li><code>DEPLOY_SSH_HOST</code>: What you used as <code>&lt;server&gt;</code>;</li>
<!-- /wp:list-item --></ul>

<p>Note that <code>DEPLOY_SSH_KEY</code> is different from the others: we are using the <em>file path </em>in the command but the secret has the <em>content</em> of the file. This happens because we will create the file during the <em>action</em> execution.</p>

<figure class="wp-block-image aligncenter size-large is-resized"><a href="https://felipeelia.com.br/wp-content/uploads/2023/01/image-4.png"><img src="https://felipeelia.com.br/wp-content/uploads/2023/01/image-4-1024x449.png" alt="" class="wp-image-2671" width="768" height="337"/></a><figcaption class="wp-element-caption">Form to create a secret in GitHub</figcaption></figure>

<p>The <code>DEPLOY_SSH_KEY</code> secret is not in this <code>env</code> section on purpose. It will be used later.</p>

<h3 class="wp-block-heading">Triggers and Events</h3>

<pre class="wp-block-code"><code>on:
  push:
    branches:
      - trunk</code></pre>

<p>The content of this section is quite self-explanatory: the steps in this file will only be executed if the <code>trunk</code> branch changes.</p>

<p>Instead of <code>push</code>, it could be <code>pull_request</code> or <code>schedule</code>, for example. </p>

<h3 class="wp-block-heading">Jobs</h3>

<p>The <code>jobs</code> section is a set of steps to be executed. Our <em>workflow </em>file has only one <em>job</em> but it is possible to have more than one. Different <em>jobs</em> run in parallel.</p>

<pre class="wp-block-code"><code>jobs:
  deploy_cloudways:
    name: Deploy to Cloudways
    runs-on: ubuntu-latest</code></pre>

<p>The <code>deploy_cloudways</code> string could be any slug, the same is valid for <code>Deploy to Cloudways</code>. These are just names.</p>

<p>The <code>ubuntu-latest</code> string specifies under which OS the job will run. It is possible to use different Linux, Windows, and macOS versions. See the <a href="https://docs.github.com/pt/actions/using-workflows/workflow-syntax-for-github-actions#escolhendo-executores-hospedados-em-github" target="_blank" rel="noreferrer noopener">full list</a>.</p>

<h3 class="wp-block-heading">Steps</h3>

<p><em>Steps</em> are the parts that make a <em>job</em> and they are executed in order.</p>

<pre class="wp-block-code"><code>    steps:
    - name: Checkout
      uses: actions/checkout@v2</code></pre>

<p>Our first step is the repository <em>checkout</em>. Usually, this will be the very first step of all the <em>workflows</em> you will see out there. It simply brings the repository content to the <em>action</em>'s environment.</p>

<h3 class="wp-block-heading">SSH Configuration</h3>

<p>This section configures SSH of the machine running our action in GitHub.</p>

<pre class="wp-block-code yml"><code>    - name: Configure SSH
      run: |
        mkdir -p ~/.ssh/
        echo "$SSH_KEY" &gt; ~/.ssh/deploy.key
        chmod 600 ~/.ssh/deploy.key
        cat &gt;&gt;~/.ssh/config &lt;&lt;END
        Host cloudways
          HostName $SSH_HOST
          User $SSH_USER
          IdentityFile ~/.ssh/deploy.key
          StrictHostKeyChecking no
        END
      env:
        SSH_KEY: $`{{ secrets.DEPLOY_SSH_KEY }}`</code></pre>

<ul><li><code>mkdir -p ~/.ssh/</code>: Creates the <code>.ssh</code> directory inside the <code>$HOME</code> folder of GH's machine.</li>

<li><code>echo "$SSH_KEY" &gt; ~/.ssh/deploy.key</code>: Puts the content of the <code>SSH_KEY</code> variable inside the <code>deploy.key</code> file.</li>

<li><code>chmod 600 ~/.ssh/deploy.key</code>: Configures the permissions of the <code>deploy.key</code> file. In this case, <code>600</code> means the owner can read and write on it but no one else is able to access it.</li>

<li><code>cat &gt;&gt;~/.ssh/config &lt;&lt;END</code>: What you see between <code>&lt;&lt;END</code> and <code>END</code> will be inserted in the <code>config</code> file of the <code>.ssh</code> directory.<ul><li><code>Host cloudways</code>: Creates a name for the following configuration. When calling SSH passing <code>cloudways</code>, the data below will be used;</li>

<li><code>HostName $SSH_HOST</code>: The server address. If you remember what we talked about the <code>env</code> section, this variable will receive the value of the <code>DEPLOY_SSH_HOST</code> secret;</li>

<li><code>User $SSH_USER</code>: Similar to <code>HostName</code> but applied to the user;</li>

<li><code>IdentityFile ~/.ssh/deploy.key</code>: Specifies the path of the key to be used when connecting to  <code>Host</code>;</li>

<li><code>StrictHostKeyChecking no</code>: Skips the server's identity verification with the <code>known_hosts</code> file.</li>
<!-- /wp:list-item --></ul>
<!-- /wp:list --></li>
<!-- /wp:list-item --></ul>

<p>Down below, we can see an <code>env</code> section, similar to that global one (declared at the beginning of our file.) Note that in this case the variable is created only for this step. Here we are passing the content of the <code>DEPLOY_SSH_KEY</code> secret to an environment variable called <code>SSH_KEY</code>. The content of this variable will be added to the <code>deploy.key</code> file, as we saw.</p>

<h3 class="wp-block-heading">Send files with <code>rsync</code></h3>

<p>In the last step, we will finally change the content of the target server. For this, we will use the <code>rsync</code> command, which synchronizes the files from one place to another. Here, the files in GitHub's machine (origin) with the files in your server (target.)</p>

<pre class="wp-block-code"><code>    - name: Send files
      run: "rsync --delete -avO $`{{ env.RSYNC_FLAGS }}` --exclude-from=$`{{ env.EXCLUDES }}` ./ $`{{ env.SSH_USER }}`@$`{{ env.SSH_HOST }}`:$`{{ env.DESTINATION }}`"
      env:
        RSYNC_FLAGS: '' #--dry-run
        EXCLUDES: bin/rsync-excludes.txt
        SSH_HOST: cloudways
        DESTINATION: "~/public_html/wp-content/"</code></pre>

<p>If the <code>rsync</code> command is something new to you, let's give a deeper look into each one of the parameters we are calling:</p>

<ul><li><code>--delete</code>: If a file does not exist in the origin, also delete it in the target.</li>

<li><code>-avO</code>: <code>-a</code> is to sync all files, folders, and symbolic links preserving their permissions. That <code>v</code> is for <code>verbose</code>, i.e., outputs to the terminal the list of changes being made. That <code>O</code> (an uppercase "o") tells <code>rsync</code> to not worry about folders' modification dates.</li>

<li><code>$`{{ env.RSYNC_FLAGS }}`</code>: As you can imagine, this will be replaced with the content of <code>RSYNC_FLAGS</code>, configured a couple of lines below. Usually, it doesn't receive anything but you can pass <code>--dry-run</code>, for example, to simulate what the command would do without changing anything in the real world.</li>

<li><code>--exclude-from=$`{{ env.EXCLUDES }}`</code>: You can pass the path of a file containing the list of everything that should be ignored by <code>rsync</code>. Check the next section to know more about it.</li>

<li><code>./</code>: The path (in the origin) that needs to be synced.</li>

<li><code>$`{{ env.SSH_USER }}`@$`{{ env.SSH_HOST }}`:$`{{ env.DESTINATION }}`</code>: The target path. The last part will be replaced by the folder in the target server. In our case, the repository only contains the <code>wp-content</code> files and the website is located in the <code>$HOME/public_html</code> folder on the server. In your case, the folder can be elsewhere, like <code>/var/www/html</code>. Access your server or search in your host configurations to get where in the server your website files are located.</li>
<!-- /wp:list-item --></ul>

<h4 class="wp-block-heading" id="about-exclusions-and-rsync">About exclusions and <code>rsync</code></h4>

<p>In the previous example, we are using a file called <code>rsync-excludes.txt</code> located in the <code>bin</code> folder of the repository. <strong>This file has a list of files and folders that should be ignored</strong> by the <code>rsync</code> command. For example:</p>

<pre class="wp-block-code plaintext"><code>*.gitignore
*.gitmodules
*.git
*.gitkeep
*.github

/bin
*rsync-excludes.txt

/uploads
/upgrade
/themes/index.php
/plugins/index.php</code></pre>

<p>Instead of using <code>--exclude-from=&lt;file&gt;</code> you can change the <code>rsync</code> call and use <code>--exclude .git --exclude README.md</code>, for example.</p>

<h2 class="wp-block-heading">Doing more with GitHub Actions</h2>

<p>If you want, it is also possible to call commands like <code><a href="https://felipeelia.com.br/o-que-e-o-composer-do-php/">composer install</a></code> or <code><a href="https://www.youtube.com/watch?v=tFqsmNrWW0M" target="_blank" rel="noreferrer noopener">npm install</a></code> during the process, so you don't have to version 3rd party files in your repo. In addition to that, the process can also be triggered by a <a href="https://felipeelia.dev/is-labeling-pull-requests-messages-a-good-idea/">Pull Request</a> creation or time periods (hourly, daily, etc.)</p>

<p>For real examples, give a look at <a href="https://github.com/10up/ElasticPress/blob/develop/.github/workflows/cypress-tests.yml" target="_blank" rel="noreferrer noopener">the ElasticPress repository</a>. There you will see actions executed daily, automated tests, deployments to WordPress.org, and more.</p>

<h2 class="wp-block-heading">Why you should not use SFTP anymore</h2>

<p>Firstly, <strong>you should be using a version control system like <a href="https://felipeelia.dev/categorias/git-en/" target="_blank" rel="noreferrer noopener">git</a>, even if you work alone.</strong> Versioning your projects in a git repo will give you control of your changes and a backup. It is also a <a href="https://felipeelia.dev/categorias/career/">requirement in most jobs</a> nowadays.</p>

<p>The process described here has several advantages over the old method of sending file by file using a program like FileZilla, for example. My favorite is that you'll be sending only the files that were changed, making it all <strong>easier and faster</strong>. We also avoid having different people updating the same file and overwriting changes one from the other.</p>

<p>I used GitHub and GitHub Actions in this post but there are several alternatives available if you want. The process of sending files to a server through a repository is called <em>Continuous Delivery</em> and is part of the <em>Continuous Integration</em>, <em>Delivery, </em>and <em>Deployment</em> topic, also known as <strong>CI/CD</strong>.</p>

<h2 class="wp-block-heading">Conclusion</h2>

<p>In this post, we saw a method to send files to a server from a git repository.</p>

<p>To access the server, an SSH key pair is needed. The public key is stored in the server, the private key is stored as a secret in GitHub.</p>

<p>We also saw how easy it is to create a <em>workflow</em> in GitHub: we just need to create a <code>.yml</code> file in the <code>.github/workflows</code> folder. To restrict access to sensitive information we can use GitHub secrets.</p>

<p>The sync between files is made through the <code>rsync</code> command, which accepts several parameters. It is also possible to exclude files from the sync through different ways: a list in a file with the <code>--exclude-from</code> parameter or several <code>--exclude</code>.</p>

<p>This post is just a glimpse of what is possible with GitHub Actions. It is also possible to use <a href="https://felipeelia.com.br/o-que-e-o-composer-do-php/">Composer</a> and <a href="https://www.youtube.com/watch?v=tFqsmNrWW0M" target="_blank" rel="noreferrer noopener">NPM</a> during the process and much more.</p>

<p>Lastly, we saw why this deployment method is much better than sending individual files via SFTP.</p>

<hr class="wp-block-separator has-alpha-channel-opacity"/>

<p>Don't forget to share the post and leave a comment!</p>
<!-- /wp:paragraph -->