How to create a docker based LAMP stack using docker on Ubuntu 20.04

The LAMP stack

LAMP is the software stack on which probably the majority of websites run. Linux represents the foundation of the stack, and the traditional implementation includes Apache as the web server, the MySQL database, and PHP as the server-side programming language. There are, however, many possible variations: MariaDB, for example, is often used in place of MySQL, of which it is a fork, and other programming languages, as Python or Perl can be used instead of PHP. In this article we will see how to implement a basic LAMP stack using docker and the docker-compose utility.

In this tutorial you will learn:

  • How to install docker and docker-compose on Ubuntu 20.04
  • How to define services and volumes using docker-compose
  • How to map host ports to container ports in the docker-compose configuration file
  • How to use bind mounts and named volumes
  • How to build a project with docker-compose
How to create a docker based LAMP stack using docker on Ubuntu 20.04

How to create a docker based LAMP stack using docker on Ubuntu 20.04

Software Requirements and Conventions Used

Software Requirements and Linux Command Line Conventions
Category Requirements, Conventions or Software Version Used
System Installed Ubuntu 20.04 or Upgraded Ubuntu to 20.04 Focal Fossa
Software docker, docker-compose
Other Root permissions to build docker containers and launch the docker service
Conventions # – requires given linux commands to be executed with root privileges either directly as a root user or by use of sudo command
$ – requires given linux commands to be executed as a regular non-privileged user
  1. Installing packages and starting the docker service

    In order to create a Docker-based LAMP stack on Ubuntu 20.04 Focal Fossa, the first thing we have to do is to install the software we need: docker itself, and docker-compose, which is an utility that let us easily organize multi-container applications using yaml configuration files. Both packages are available in the Ubuntu official repositories. We can install them via apt:

    $ sudo apt install docker docker-compose
    

    After the installation is performed we must start the docker service and enable it at boot. We can perform both operations with one single command:

    $ systemctl enable --now docker
    
  2. Project setup

    The first step in our journey consists into the creation of the directory we will use as the root of our project. For the sake of this article we will call it linuxconfig. Inside this directory we will create another one, DocumentRoot, which will host our website files. We can create both directories at once using the -p option of the mkdir command:

    $ mkdir -p linuxconfig/DocumentRoot
    


    Inside the linuxconfig directory, we define the docker-compose configuration for our project inside a yaml file, which by default should be called docker-compose.yml. There are three main stanzas we can use in the configuration file: services, volumes and networks.

    Each section is used to configure the corresponding aspect of a project. In this tutorial we will use only the first two. We will implement the components of the LAMP stack as services inside their own separate containers.

    The containers created with docker-compose will be members of the same network and therefore will be able to talk to each other by default. In the network, each container will be able to reference the others by an hostname identical to their name, or by the name used to define the service implemented by the container.

    By default containers will be named using the name of the directory containing the configuration file as prefix. In this case, for example, the container used for a service called php-httpd, will be named linuxconfig_php-httpd_1.

  3. Defining the php + httpd service

    The first service we will define in the configuration file will include PHP as the Apache web server module. We will use one of the official PHP images available on dockerhub as the base for our container, specifically the one with the -apache suffix, which provides the setup we mentioned above. Let’s start writing our configuration:

    version: '3.7'
    
    services:
        php-httpd:
            image: php:7.3-apache
            ports:
                - 80:80
            volumes:
                - "./DocumentRoot:/var/www/html"
    
    

    The first thing we specified in the configuration file is version. With this instruction we declare what specific version of the compose file we are going to use. At the moment of writing, version 3.7 is the latest and recommended one.

  4. After declaring the compose file version, we started writing the service stanza; inside of it we define the services which will compose our LAMP stack. We called the first service php-httpd. The service name is completely arbitrary, but is always a good habit to use one that is meaningful in the context of the project.

    The image instruction is used to specify on which image the container should be based, in this case php:7.3-apache.

    The ports instruction is used to expose ports on the container, and to create a map between host ports and container ports. Such map is defined by separating the ports with a :. On the left side we specify the host port, and on the right, the port inside the container it should be mapped to. In this case we mapped port 80 on the host to port 80 on the container, since it is the default port used by the Apache web server.

    The last instruction we used is volumes: with it we can specify a mapping between a named volume or a path (relative or absolute) on the host system to a path on the container, on which it will be mounted.

    In our setup, the ./DocumentRoot directory will host the site files: it will be mounted on the /var/www/html directory inside the container, because the latter is the document root used by the default Apache VirtualHost. Such setup is called a bind mount and is especially useful during development because the changes we make on the project files, are immediately reflected inside the container. The downside of this configuration is that it establishes a dependency between the container and the host machine file structure, diminishing one of the main advantage of using Docker: portability.

    The directory to be mounted inside the container will be created automatically if it doesn’t exist when the docker-compose up command is launched: in that case it will be owned by root if not otherwise specified.

    Inside the DocumentRoot directory we can now create an index file, and try to build our project to verify the setup is working:

    $ echo "<?php phpinfo();" > DocumentRoot/index.php
    $ sudo docker-compose up -d
    

    After executing the command, the needed docker images will be downloaded from dockerhub and the containers we will be created with the settings we provided and run in background (they will not block the terminal), because of the -d option we provided to the docker-compose command. With the project up and running, if we navigate to localhost with our browser, we should see the following page:


    phpinfo

    The phpinfo page

    To stop the project, from the directory hosting the docker-compose.yml file, we can run:

    $ sudo docker-compose stop
    

    Defining the MariaDB service

    An essential part of the LAMP stack is the database layer. In our configuration we will use MariaDB and its official docker image available on dockerhub:

    version: '3.7'
    
    services:
        php-httpd:
            image: php:7.3-apache
            ports:
                - 80:80
            volumes:
                - "./DocumentRoot:/var/www/html"
    
        mariadb:
            image: mariadb:10.5.2
            volumes:
                - mariadb-volume:/var/lib/mysql
            environment:
                TZ: "Europe/Rome"
                MYSQL_ALLOW_EMPTY_PASSWORD: "no"
                MYSQL_ROOT_PASSWORD: "rootpwd"
                MYSQL_USER: 'testuser'
                MYSQL_PASSWORD: 'testpassword'
                MYSQL_DATABASE: 'testdb'
    
    volumes:
        mariadb-volume:
    


    Inside the services stanza, we defined another service and call it mariadb and with the image instruction we specified we want to use the 10.5.2 version of the official image.

    In the previous service definition we used a bind mount. This time, instead, we used a proper docker named volume, to be mounted on /var/lib/mysql inside the container (it is the default data directory used by MariaDB). Unlike a bind mount, named volumes don’t create dependencies of the container on the host filesystem structure. Completely managed by Docker, they are the recommended method of persisting data which otherwise would be lost when containers are destroyed.

    Named volumes can be defined in the main volumes stanza of the configuration file and can be referenced from the volumes subsection of each defined services. In this case we called our volume mariadb-volume.

    As a next step we defined the value of some environment variables used to influence the container behavior. Environment variables are defined in the environment section of a service definition. The variables we defined in this case have the following effect:

    Variable Effect
    TZ Set the timezone used by the MariaDB server
    MYSQL_ALLOW_EMPTY_PASSWORD Enable or disable the use of blank password for the db root user
    MYSQL_ROOT_PASSWORD This is a mandatory variable and is used to set the db root user password
    MYSQL_DATABASE Optionally used to specify the name of database to be created on image startup
    MYSQL_USER Optionally used to specify the name of a user that will be created with superuser permissions for the database specified with MYSQL_DATABASE
    MYSQL_PASSWORD Used to specify the password for the user created with the name provided by MYSQL_USER

    At this point we should have a working web server able to work with PHP, and a database to store our data.

    Bonus – phpMyAdmin

    Our basic LAMP stack should now be complete. As a bonus, we may want to add phpMyAdmin to it, in order to easily control our MariaDB database from a user-friendly web interface. Let’s add the related service definition to our docker-compose configuration:

    version: '3.7'
    
    services:
        php-httpd:
            image: php:7.3-apache
            ports:
                - 80:80
            volumes:
                - "./DocumentRoot:/var/www/html"
    
        mariadb:
            image: mariadb:10.5.2
            volumes:
                - mariadb-volume:/var/lib/mysql
            environment:
                TZ: "Europe/Rome"
                MYSQL_ALLOW_EMPTY_PASSWORD: "no"
                MYSQL_ROOT_PASSWORD: "rootpwd"
                MYSQL_USER: 'testuser'
                MYSQL_PASSWORD: 'testpassword'
                MYSQL_DATABASE: 'testdb'
    
        phpmyadmin:
            image: phpmyadmin/phpmyadmin
            links:
                - 'mariadb:db'
            ports:
                - 8081:80
    
    volumes:
        mariadb-volume:
    

    We named our service phpmyadmin and configured it to use the phpmyadmin/phpmyadmin image from dockerhub. We also used the links keyword for the first time; what is this for? As we already know, by default, and with no special configurations needed, all the containers created in the same docker-compose configuration are able to talk to each other. The phpMyAdmin image is configured to reference a running database container by the db name, therefore we need to create an alias with the same name for our mariadb service. This is exactly what links is used for: to define extra aliases to reach a service from another one.

    Inside the service definition we also mapped port 8081 of our host machine, to port 80 inside the container (port 80 is already mapped to the same port inside the php-httpd container). The phpMyAdmin interface will be therefore reachable at the localhost:8081 address. Let’s rebuild our project and verify it:

    $ sudo docker-compose up -d --build
    

    phpmyadmin

    The PhpMyAdmin login page

    We can login with the credentials we defined for our database service, and verify that the testdb database has been created:


    phpmyadmin-testdb

    PhpMyAdmin homepage


    Using a custom image for a service

    In the examples above we always used vanilla images in our services definition. There are cases in which we may want to use custom docker images based on them. For example, say we want to build the php-httpd service, but include an additional php extension: how can we do it? On the root of the project, we define a new directory, and for convenience name it after the service:

    $ mkdir php-httpd
    

    Inside this directory we create a Dockerfile, used to extend the base image, with the following content:

    FROM php:7.3-apache
    LABEL maintainer="egdoc.dev@gmail.com"
    
    RUN apt-get update && apt-get install -y libmcrypt-dev \
        && pecl install mcrypt-1.0.2 \
        && docker-php-ext-enable mcrypt
    

    Back in our docker-compose.yml file, we modify the definition of the php-httpd service. We cannot reference the image directly as we did before. Instead, we specify the directory containing our custom Dockerfile as the build context:

    version: '3.7'
    
    services:
        php-httpd:
            build:
                context: ./php-httpd
            ports:
                - 80:80
            volumes:
                - "./DocumentRoot:/var/www/html"
    [...]
    

    In the build section we define configurations that are applied at build time. In this case, we used context to reference the directory containing the Dockerfile: said directory is used as build context, and its content is sent to the Docker daemon when the container is built. To apply the modification we must re-build the project.

    By the way, to know more about additional extensions in the php docker image, you can take a look at the official documentation, and specifically the PECL extensions section.

    Conclusions

    In this tutorial we saw how to build a basic LAMP stack using the container technology with Docker and docker-compose. We saw how to define the various services inside the docker-compose.yml configuration file, and how to configure bind mounts, named volumes and host-container ports mapping. We also saw how to use custom images. You can take a look at the docker-compose reference for the detailed list of instructions that can be used inside the docker-compose configuration file.