An App From Scratch: Part 9 – Setting Up Our Production Server And Deployment (US1-C4)

9 minute reading time; ~1720 words


Welcome and hello!

This post is going to take the work we’ve done so far, and put it where it can be used. We’re going to walk through designing an automated deployment process, and some cleanup that we have to do around the branding as part of taking it to production.

Let’s dig into it, and put our work in front of the world!

If you haven’t read the previous posts, I’d suggest you start at An App From Scratch: Part 1 – What To Build. You can also find all the related documents and code here: Building Tailgunner.

What Do I See?

Let’s take a look at the final card in this feature.

What I Need To Do

Reviewing the system diagram and acceptance criteria, we can break down these tasks:

  • Set up server environment
  • Add credentials to GitHub
  • Create a GitHub Action build / push pipeline
  • Port the current homepage into Laravel and update branding

Putting It Into Action

Set up server environment

First of all, I want to lay out the deployment structure on the server.

Bash
|-- base directory
|   └── deployments                            <- Where we upload the deployment files
|       └── deploy-2025-01-16-14-48-32.tar.gz  <- A deployment file
|       └── releases                           <- Where each release gets unpacked to
|           └── 2025-01-16-14-48-32            <- An unpacked release

|       └── workflows                          <- Where the workflow script gets stored
|           └── deploy-to-prod.sh              <- The workflow script
    
    # public_html gets symlinked (shortcut) to the current release folder's code
|   └── public_html -> /home/<username>/deployments/releases/2025-01-16-14-48-32/src

|   └── shared          <- Where we will store files that don't change in each release
|       └── .env        <- The production environment settings
|       └── storage     <- Laravel's file storage directory

This structure will allow us to make instant switches between releases, and gives us the ability to switch back to a previous release by re-linking the public_html directory to the desired one.

Next, since I’m using cPanel for my hosting, there were a few extra server configuration things to do.

Bash
# Go to the cPanel settings for the user (as root)
$ cd /var/cpanel/userdata/<username>/

# Edit the config for the domain, and change the DocumentRoot
# (where files are served from), from public_html to public_html/public
# because that's how Laravel serves its content.
$ nano tailgunner.app
documentroot: /home/<username>/public_html/public

# Make the same change to our https config
$ nano tailgunner.app_SSL
documentroot: /home/<username>/public_html/public

# Get cPanel to rebuild our httpd configurations
$ /scripts/rebuildhttpdconf
Built /etc/apache2/conf/httpd.conf OK

# Restart the webserver so the changes take effect
$ systemctl restart httpd

The server needs to only be restarted once. After that, we can change the symlink for public_html that we created earlier, and should have no issues.

For GitHub, I granted the account jailed shell access, and generated an SSH key set using the ssh-keygen command.

Bash
$ ssh-keygen -t ed25519 -f ~/.ssh/github_deploy

The other thing we have to do is add our new key into the authorized_keys list for the account, so GitHub can use it to access the server.

Bash
$ cat ~/.ssh/github_deploy.pub >> ~/.ssh/authorized_keys

Add credentials to GitHub

To configure GitHub to be able to access my production server, I need to set up a new Environment and add secrets for our deployment script to use.

To do this, we go to the Environment settings for the Tailgunner repo (Settings -> Environments), and created a new environment. After that, we add a few secrets:

  • the SERVER_HOST to contain the address of the server.
  • the SERVER_SSH_KEY to hold the SSH private key that we just set up.
  • the SERVER_USER has the username we’ve set up the private key for.

To get the contents of the key to put into the SERVER_SSH_KEY, we just have to read it from the server and copy the contents into the new secret.

Bash
$ [tailgunner8vk@fire ~]$ cat ~/.ssh/github_deploy
-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

NOTE: GitHub secrets let you input or overwrite their contents but will not display previous values.

Create a GitHub Action build / push pipeline

I thought this step would be the easiest, but it took a full day of effort to get right. I’m going to outline what the main script does here (and highlight some specific pieces you might find useful).

To help guide you, under the Steps section (line 16), you’ll see the steps the script follows. Each step is named and will appear in the action’s run log as well. I’ve replaced parts of the script with ... so we can focus on the most interesting pieces, but you can find the whole script here.

.github/workflows/deploy.yml
name: Build and Deploy

on:
    push:
      branches:
        - main
        - '**' # Allow all branches, for testing purposes.

    workflow_dispatch: # Allow manual triggers

jobs:
  deploy:
    # Define the environment name so we can get the settings from GitHub
    environment: Tailgunner Production

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          # Shallow clone for faster checkout
          fetch-depth: 1

      - name: Setup PHP
      - name: Install Composer dependencies

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          ...
          # Specifies non-default lock file location
          cache-dependency-path: './src/package-lock.json'

      - name: Install NPM dependencies & build assets
      - name: Create deployment package

      - name: Copy deployment package and script
        uses: appleboy/scp-action@master
        with:
          ...
          # Copy both the deployment archive and script to the server
          source: "src/deploy-*.tar.gz,.github/workflows/deploy-to-prod.sh"
          
          # Into the deployments directory
          target: "~/deployments/"
          
          # Remove the first part of the path
          # src/deploy-*.tar.gz                    ~/deployments/deploy-*.tar.gz
          # .github/workflows/deploy-to-prod.sh    ~/deployments/workflows/deploy-to-prod.sh
          strip_components: 1

          # Overwrite existing files so we can put new deploy script versions up
          overwrite: true

      - name: Unpack and finalize
        uses: appleboy/ssh-action@master
        with:
          ...
          script: |
            # Make the script executable
            chmod +x ~/deployments/workflows/deploy-to-prod.sh
            
            # Run it on the server
            ~/deployments/workflows/deploy-to-prod.sh

After a lot of testing (64 commits and 59 workflow runs 😪), everything works and now I can merge the PR, enabling future merges to auto-deploy to the production server!

Port the current homepage into Laravel and update branding

Currently, there’s a static page on the domain that lists the posts in this series. With automated deployments now in place, it’s time to take that content and replace Laravel’s default welcome page. Along with this, we’ll also to update the branding from Laravel to Tailgunner. PR, PR, PR, PR

The changes we’ve made are:

  • Replaced the default main page with our static website content (a list of blog posts)
  • Replaced all instances of the app logo and favicon with the Tailgunner logo
  • Created a new CSS file to contain the styles for the main page
  • Added a display of the PHP and Laravel versions to the main page
  • Updated the dashboard page to replace the Laravel default content
  • Fixed the README to account for the new logo location

Wrapping Up

The single biggest challenge in this one was in figuring out how to create the action, and getting it working correctly. There’s not an easy way to do this part of the process. Every single commit triggers the action to test, which took between 30-45 seconds per run. As you can read from the highlighted lines in my commit log below, it can at times be frustrating.

Commit Log – Deploy Script
* 69d3843 US1-C4 First version of prod deploy script
* 02a46cc US1-C4 Move the scripts to the correct location
* 8b65df8 US1-C4 Enable for all branches
* 9006597 US1-C4 Set working directory
* d5382dc US1-C4 Fix paths
* 2a7ac12 US1-C4 Try tar a different way
* 0a5812e US1-C4 Break up the copy and deploy steps
* 27b8936 US1-C4 Debug this step
* bbd063d US1-C4 Test just the scp action
* 45ba536 US1-C4 Debug if the secrets are working
* 17cdf51 US1-C4 Set the environment to see if that helps
* ca500c9 US1-C4 Clean up some debugging
* 4a13e05 US1-C4 Restored the original deployment script
* 5c7abfd US1-C4 Updated deploy scripts
* c4e9d4c US1-C4 Removed some deploy steps
* 93530f4 US1-C4 Changes to where we're putting the upload
* 4e0b99a US1-C4 Changed the paths for deployment
* 8d7ef41 US1-C4 Added some deployment setup steps
* bd90a79 US1-C4 Added some deployment setup steps
* e21d2ac US1-C4 See of we can fix the file naming issue
* 85b028f US1-C4 Clean up and fix the release pathing
* 4bbfcdb US1-C4 Fix some build configs
* 2d57513 US1-C4 Fix the deploy
* 13b68d7 US1-C4 We're maybe already in this dir
* 36daae8 US1-C4 Vite build is being annoying
* ce8c085 US1-C4 Try again to get Vite to compile
* ba586ed US1-C4 More changes to see what's wrong with Vite
* cc4a381 US1-C4 Try again with Vite
* 94fc3fd US1-C4 Rewrite the npm
* 956b9c4 US1-C4 Missing dayjs
* a6c67bb US1-C4 Revert our Vite changes and remove the hot file
* 7b23c66 US1-C4 See if this build works
* db25f12 US1-C4 Fix the dependencies
* 2d48b6a US1-C4 Add some debugging
* 37b8c5c US1-C4 Change some build pieces
* 3182b85 US1-C4 Change the deploy script to set up an env
* a1d9b4d US1-C4 Changes to build script
* 3fa6314 US1-C4 Re-point web to the new release path
* 6e8df0a US1-C4 Add some file cleanup
* f210b4f US1-C4 Bypass production warning
* b276cdf US1-C4 Move deploy script to new file
* 6e9f68e US1-C4 The deploy script ended up in the wrong place, also we forgot to add it
* 478357f US1-C4 Minor optimizations
* 55301b4 US1-C4 Build script optimizations and re-aligned package-lock.json
* 5641b29 US1-C4 Revert file handling change
* d350c8b US1-C4 File handling fix
* dd0679c US1-C4 Fix the filename
* a9ddff7 US1-C4 Testing
* 859fc0d US1-C4 Another test
* ea96d5f US1-C4 Try again
* ce173c3 US1-C4 Test
* 5ab53e4 US1-C4 Another test
* ad453c2 US1-C4 Test
* cc80ee9 US1-C4 Maybe this works?
* f8bac97 US1-C4 Re-organize the build
* 70eb4d2 US1-C4 Move the Vite vars to GitHub Actions defined environment
* 01f74b8 US1-C4
* 8f56b57 US1-C4 Do we need these vars for the app to build properly?
* 8cdb640 US1-C4 Minor tweaks
* 6940d47 US1-C4 Clean up some path name handling
* 1b8f85c US1-C4 Some small changes
* 27a89cb US1-C4 Message tweaks
* 86322e4 US1-C4 Don't need this whitespace
* 1f71469 US1-C4 Remove debugging flag

I originally expected this process to take an hour or two, but when I got into the weeds, it turned out that there were a lot more pieces to get working than I expected.

While it was more involved than I originally expected, it was one of the most interesting parts of this work so far, learning how Actions work, and figuring out how to deploy to a cPanel server.

I hope along the way, you also found something interesting or helpful in my work! Look for the next part soon, where we’re going to zoom out a bit and talk about the feature we’ve delivered, and explore what’s next.

Leave me a comment if you found this helpful, or you found something I could do better!

Cheers!


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *