Deploying Drupal 8

By Kevin, June 18th, 2019

I see people ask or state they are confused about how to deploy Drupal 8 when most (if not all) hosting platforms disallow running tools like Composer or NPM in production. Since Composer is the defacto package management tool for PHP, the natural inclination is to push your code changes and fetch packages on the remote server. But services like Acquia, Pantheon and other managed hosting services don't allow for running such tools.

What you need to do instead is deploy all the code to the target server ready to go. How do you approach this, you ask? Well, it's not that hard. Once you get into the habit and put it into practice, its a very smooth process.

The goal is you will want to have all the code in version control. For some this is where the confusion sets in. Why use a package manager if you're just committing all the code anyway?

Well, what you want to do is have two repositories. One acts as the authoritative repository (i.e. GitHub, GitLab) where developers push all their code. The second one is your deployment target and lives in your remote environment (dev/stage/prod) that no one touches, except for your continuous integration service. This could be TravisCI, CircleCI or similar. If you want to use a private repository, those services cost money to integrate with. They're free for public repositories.

I suggest GitLab. You can have unlimited private repositories with unlimited contributors - and they will give you GitLab CI for free! In fact, my site code is hosted in my own GitLab, and the deployment happens from there to my AWS instance. I run many projects from GitLab, I also run many more from GitHub using Travis CI. All of my deployment workflows are very similar.

Build & Deploy Process

For this site, my workflow is very simple. Any local development or updates I run locally in my Docker stack, make the appropriate changes in branches

Here is the .gitlab-ci.yml instruction file that powers the deployment of my website:


stages:
  - build
  - test
  - deploy

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - vendor/

before_script:
  - echo -e "deb http://deb.debian.org/debian jessie main\ndeb http://security.debian.org jessie/updates main" > /etc/apt/sources.list
  - apt-get upgrade
  - apt-get update
  - docker-php-ext-enable mbstring mcrypt mysqli pdo_mysql intl gd zip bz2
  - composer --verbose self-update
  - composer global require hirak/prestissimo:0.3.9
  - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )'
  - eval $(ssh-agent -s)
  - echo -n "$SSH_PRIVATE_KEY" | ssh-add - >/dev/null
  - mkdir -p ~/.ssh
  - chmod 700 ~/.ssh
  - 'echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
  - git config --global user.email "[[ REDACTED ]]"
  - git config --global user.name "GitLab Runner"
  - git config --local core.excludesfile false

variables:
  ARTIFACT_REMOTE_NAME: "aws"
  ARTIFACT_REMOTE_URL: "USERNAME@SERVER:/path/to/site/.git"
  ARTIFACT_BRANCH_NAME: "$CI_COMMIT_REF_NAME-build"

build:
  stage: build
  image: tetraweb/php:7.1
  script:
    - composer install --no-interaction

test:
  stage: test
  dependencies:
    - build
  image: tetraweb/php:7.1
  script:
    - composer install --no-interaction

deploy to server:
  stage: deploy
  image: tetraweb/php:7.1
  environment:
    name: production
  script:
    - git checkout -b $ARTIFACT_BRANCH_NAME
    - echo "Running composer install."
    - composer install --no-interaction --no-dev
    - echo "Stripping non-essential directories/files from deployment."
    - rm -rf docroot/core/*.txt
    - rm -rf tools
    - rm -rf drush
    - rm -rf scripts
    - rm .gitlab-ci.yml
    - rm docroot/.eslintrc.json
    - rm docroot/.editorconfig
    - rm docker-compose.yml
    - rm .ahoy.yml
    - rm .editorconfig
    - rm phpunit.xml.dist
    - git remote add $ARTIFACT_REMOTE_NAME $ARTIFACT_REMOTE_URL
    - git add --force .
    - git commit -m "Creating build artifact and deploying as $ARTIFACT_BRANCH_NAME." >/dev/null
    - git push $ARTIFACT_REMOTE_NAME $ARTIFACT_BRANCH_NAME --force
  only:
    - master

Most services use configuration files committed with the project to inform the continuous integration service how the project should be built, tested, and deployed. That is the purpose of the .gitlab-ci.yml file. The documentation on this is quite extensive, so I won't go too deep into it here.

Once you add the file to your project and push to the repository on GitLab, the the continuous integration service will come to life. There are 3 jobs that leverage each stage (build, test, and deploy).

My site is not complicated, so the build and test stages just ensure that Composer can successfully build my project from scratch (Composer is installed in the build service on GitLab). On real projects, my test stage would likely have steps to run unit or functional tests on custom modules. Any failure in any job or stage will halt the process and produce an error for me to review. This is crucial, because it prevents any failing code from being deployed anywhere.

The final job only triggers when code is merged to the master branch. This performs the actual deployment. I instruct the continuous integration runner to check out a new branch (in this case, master-build), run Composer install again (this time with no dev dependencies), and remove any files I do not want to deploy to the web server. The last steps add all files that are remaining, and push to the remote server.

GitLab CI pipeline runner
All jobs/stages executed successfully!

The artifact that is built is never committed back to the repository everyone is committing code to. The main repository should now contain nothing but custom code, custom themes, and project related files. The core of Drupal is not committed, nor is the vendor folder. Composer acts as the complete project blueprint in this regard.

A clean Drupal 8 repository.
A clean Drupal 8 repository.