[2643 Aufrufe]

1.2 Entwicklungsumgebung

Da mein Setup oft auf Interesse stößt, habe ich mich entschieden dies im Rahmen dieser Reihe einmal vorzustellen. Ich werde mich aber auf die Grundlage und nur eine PHP-Version beschränken. Auch wenn ich nur diesen einen Weg zeige, gibt es natürlich noch viele weitere Wege, um eine Entwicklungsumgebung aufzubauen. Dieser hat sich für mich als ausreichend flexibel und stabil erwiesen. Ich setzte bei der täglichen Arbeit auf Linux und die Virtualisierung mit Docker.

Installation der nötigen Pakete

Unter Linux sollte eine aktuelle Version von Docker installiert werden. Die in den Paketquellen enthaltene ist meist veraltet und muss erst entfernt werden.

sudo apt-get remove docker docker-engine docker.io containerd runc
sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo apt-key fingerprint 0EBFCD88
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

Ich weiß, sieht kompliziert aus, aber im Prinzip teilen wir Linux nur mit, wo die Paketquellen für die aktuelle Version von Docker sind und installieren es dann inklusive der Abhängigkeiten.

Nun benötigen wir noch Docker-Compose:

sudo curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose

Die Installation von Docker unter Windows ist hier beschreiben und unter Mac hier beschrieben.

Einrichten der Umgebung

Nun benötigen wir ein Docker-Image, damit Docker etwas tut. Man könnte ein fertiges Image benutzen, leider habe ich nie eins gefunden, dass meine Anforderungen erfüllt. Bei mir kommt also ein modifiziertes PHP8.1-Image zum Einsatz. Ich möchte bei mir quasi ein Shared-Hosting für alle Projekt realisieren. Die einzelnen Projekte werden über die Url aufgerufen und diese wird einfach auf die Verzeichnisse gemappt.

Ich habe einen Ordner, in dem alle Projekte liegen. Bei mit liegt er unter /mnt/projects. Da ich mehrere Kunden habe, die ihrerseits wieder mehrere Projekte habe, gibt des entsprechend für jeden Kunden einen Ordner mit einem Unterordner pro Projekt. In diesen Projekten kann es dann mehrere virtuelle Hosts geben, z. B. für verschiedenen Versionen. Klinkt auch sehr kompliziert, ist aber ganz einfach:

/mnt/
└── projects/
    ├── kunde01/
    │   ├── projekt01/
    │   │   └── vhosts/
    │   │       ├── cto3/
    │   │       └── cto4/
    │   └── projekt02/
    │       └── vhosts/
    │           └── cto4/
    └── kunde02/
        ├── projekt01/
        │   └── vhosts/
        │       ├── cto3/
        │       └── cto4/
        └── projekt02/
            └── vhosts/
                ├── cto3/
                └── cto4/

In diesem Beispiel liegen die Contao-Installationen direkt in den ctoX-Ordnern.

Alle Dateien, die wir erstellen, werden in einem Ordner liegen. Ich nenne ihn in diesem Tutorial /mnt/docker/. Es ist nicht wichtig, wo die Dockerdateien liegen, solange sie sich in einem Ordner befinden.

Dockerfile

Ich verwende das folgende Dockerfile. Es ist auf jeden Fall optimierungsfähig, tut bei mir aber seinen Dienst und Speicher habe ich genug, sodass ich vom Zusammenfassen der Kommandos abgesehen habe. Wir erstellen die Datei /mnt/docker/Dockerfile mit folgendem Inhalt.

###
## PHP-Image festelegen
##
FROM php:8.1-apache

##
## Maintainer setzen
##
LABEL maintainer="patrick.froch@easySolutionsIT.de"

##
## ARGS
##
ARG APACHE_RUN_USER=1000
ARG APACHE_RUN_GROUP=1000
ARG PHP_OPENSSL=yes

##
## User und Gruppe mit den richtigen ids erstellen
##
RUN groupmod -g $APACHE_RUN_GROUP www-data && usermod -u $APACHE_RUN_USER -g $APACHE_RUN_GROUP www-data

##
## Set hostname
##
RUN echo dev_php81 > /etc/hostname

##
## Zeitzone setzen
##
RUN ln -fs /usr/share/zoneinfo/Europe/Berlin /etc/localtime && dpkg-reconfigure -f noninteractive tzdata

##
## Abhängigkeiten installieren
##
RUN apt-get update && apt-get upgrade -y && apt-get install --no-install-recommends -y\
git libbz2-dev libc-client-dev libfreetype6-dev libgpgme11-dev libicu-dev libjpeg62-turbo-dev\
libkrb5-dev libpng-dev libxml2-dev libzip-dev nano unzip wget zip


##
## Core-Erweiterungen installieren
##

# Install bcmath
RUN docker-php-ext-install bcmath

# Install bz2 (need libbz2-dev)
RUN docker-php-ext-install bz2

# Install calendar
RUN docker-php-ext-install calendar

# Install exif
RUN docker-php-ext-install exif

# Install (need libfreetype6-dev libjpeg62-turbo-dev libpng-dev)
RUN docker-php-ext-configure gd --with-freetype=/usr/include/ --with-jpeg=/usr/include/ && docker-php-ext-install -j$(nproc) gd

# Install gettext
RUN docker-php-ext-install gettext

# Install imap (need libc-client-dev libkrb5-dev "ARG PHP_OPENSSL=yes")
RUN docker-php-ext-configure imap --with-kerberos --with-imap-ssl && docker-php-ext-install imap

# Install intl (need libicu-dev)
RUN docker-php-ext-configure intl && docker-php-ext-install intl

# Install mysqli
RUN docker-php-ext-install mysqli

# Install pcntl
RUN docker-php-ext-install pcntl

# Install pod
RUN docker-php-ext-install pdo

# Install pdo_mysql
RUN docker-php-ext-install pdo_mysql

# Install soap (need libxml2-dev)
RUN docker-php-ext-install soap

# Install sockets
RUN docker-php-ext-install sockets

# Install (need zlib1g-dev libzip-dev zip)
RUN docker-php-ext-install zip

##
## PECL-Erweiterungen installieren
##
RUN pecl install xdebug-3.1.4 && docker-php-ext-enable xdebug

##
## php.ini auf Vorlage für Entwicklung umstellen
##
RUN mv $PHP_INI_DIR/php.ini-development $PHP_INI_DIR/php.ini

##
## Enable vhosts
##
RUN a2enmod vhost_alias

##
## Enable mod_rewrite
##
RUN a2enmod rewrite

##
## Fix sendmail
##
RUN echo "127.0.0.1 easysolutionsit.de $(hostname)" >> /etc/hosts

##
## Fix AH00558: apache2: Could not reliably determine the server's fully qualified domain name
##
RUN echo "ServerName dev_php81" >> /etc/apache2/apache2.conf

##
## load composer
##
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
RUN php composer-setup.php
RUN php -r "unlink('composer-setup.php');"
RUN mv composer.phar /usr/local/bin/composer
RUN chmod +x /usr/local/bin/composer

##
## set file owner (für composer update cache)
##
RUN chown -R www-data:www-data /var/www/

##
## Apache neu starten
##
RUN service apache2 restart

Da dies kein Dockerkurs ist, werde ich an dieser Stelle nicht auf die einzelnen Zeilen eingehen. Im Internet gibt es genug Beiträge, die sich um die Details von Docker kümmern. Im Zweifelsfall lohnt sich ein Blick in die Dokumentation.

Docker-Compose

Da wir auch noch einen Container für die Datenbank benötigen, möchte ich es später einfach mit docker-composer up -d starten. Wir legen also noch eine /mnt/docker/docker-compose.yml an:

version: '3.8'

services:
    mariadb:
        container_name: "dev_mariadb"
        image: mariadb:10
        networks:
            Ctocb_dev_net:
                ipv4_address: 172.0.0.2
        ports:
            - "3306:3306"
        volumes:
            - "./my.cnf:/etc/mysql/my.cnf"
            - "/mnt/projects/mariadb:/var/lib/mysql"
            - "/var/log/mysql"
        env_file:
            - ".secret.env"

    php74:
        container_name: "dev_php81"
        image: dev_php81:1.3.0
        networks:
            Ctocb_dev_net:
                ipv4_address: 172.0.0.81
        volumes:
            - "./000-default.conf:/etc/apache2/sites-available/000-default.conf"
            - "./custom.ini:/usr/local/etc/php/conf.d/custom.ini"
            - "/mnt/projects:/mnt/projects"
        build:
            context: "."
            args:
                - APACHE_RUN_USER=1000
                - APACHE_RUN_GROUP=1000
        depends_on:
            - mariadb

    networks:
    Ctocb_dev_net:
        driver: bridge
        ipam:
            driver: default
            config:
                - subnet: 172.0.0.0/24
                  ip_range: 172.0.0.0/24
                  gateway: 172.0.0.254

Nun benötigen wir die Verzeichnisse für unsere Projekte:

  • Ersten virtuellen Host /mnt/projects/kunde01/projekt01/vhosts/cto4
  • Datenbanken: /mnt/projects/mariadb

Außerdem benötigen wir noch die folgenden Dateien für die Konfiguration der Dienste:

  • Zugangsdaten: .secret.env
  • MariaDB: my.cnf
  • Apache: 000-default.conf
  • PHP: custom.ini

Zugangsdaten

Für die Zugangsdaten ist die Datei .secret.env zuständig. Sie liegt im selben Verzeichnis, wie alle anderen auch.

# MariaDB-Zugangsdaten:
MYSQL_USER=testuser
MYSQL_PASSWORD=testpassword
MYSQL_ROOT_PASSWORD=testadminpassword

# FIX: TERM environment variable not set - see https://github.com/dockerfile/mariadb/issues/3
TERM=dumb

Konfiguration MariaDB

Die Konfiguration der Datenbank habe ich aus dem Image übernommen und nur geringfügig angepasst. Da ich kein Datenbankspezialist bin, habe ich mich nie eingehender damit beschäftigt. Solange Contao zufrieden ist, bin ich es auch. Die Datei /mnt/docker/my.cnf wird im selben Verzeichnis gespeichert, wie das Dockerfile und die docker-compose.yml.

# MariaDB database server configuration file.
#
# You can copy this file to one of:
# - "/etc/mysql/my.cnf" to set global options,
# - "~/.my.cnf" to set user-specific options.
#
# One can use all long options that the program supports.
# Run program with --help to get a list of available options and with
# --print-defaults to see which it would actually understand and use.
#
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html

# This will be passed to all mysql clients
# It has been reported that passwords should be enclosed with ticks/quotes
# escpecially if they contain "#" chars...
# Remember to edit /etc/mysql/debian.cnf when changing the socket location.
[client]
port        = 3306
socket      = /var/run/mysqld/mysqld.sock

# Here is entries for some specific programs
# The following values assume you have at least 32M ram

# This was formally known as [safe_mysqld]. Both versions are currently parsed.
[mysqld_safe]
socket      = /var/run/mysqld/mysqld.sock
nice        = 0

[mysqld]
#
# * Basic Settings
#
#user       = mysql
pid-file    = /var/run/mysqld/mysqld.pid
socket      = /var/run/mysqld/mysqld.sock
port        = 3306
basedir     = /usr
datadir     = /var/lib/mysql
tmpdir      = /tmp
lc_messages_dir = /usr/share/mysql
lc_messages = en_US
skip-external-locking
#
# Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
#bind-address       = 127.0.0.1
#
# * Fine Tuning
#
max_connections     = 100
connect_timeout     = 5
wait_timeout        = 600
max_allowed_packet  = 16M
thread_cache_size       = 128
sort_buffer_size    = 4M
bulk_insert_buffer_size = 16M
tmp_table_size      = 32M
max_heap_table_size = 32M
#
# * MyISAM
#
# This replaces the startup script and checks MyISAM tables if needed
# the first time they are touched. On error, make copy and try a repair.
myisam_recover_options = BACKUP
key_buffer_size     = 128M
#open-files-limit   = 2000
table_open_cache    = 400
myisam_sort_buffer_size = 512M
concurrent_insert   = 2
read_buffer_size    = 2M
read_rnd_buffer_size    = 1M
#
# * Query Cache Configuration
#
# Cache only tiny result sets, so we can fit more in the query cache.
query_cache_limit       = 128K
query_cache_size        = 64M
# for more write intensive setups, set to DEMAND or OFF
#query_cache_type       = DEMAND
#
# * Logging and Replication
#
# Both location gets rotated by the cronjob.
# Be aware that this log type is a performance killer.
# As of 5.1 you can enable the log at runtime!
#general_log_file        = /var/log/mysql/mysql.log
#general_log             = 1
#
# Error logging goes to syslog due to /etc/mysql/conf.d/mysqld_safe_syslog.cnf.
#
# we do want to know about network errors and such
#log_warnings       = 2
#
# Enable the slow query log to see queries with especially long duration
#slow_query_log[={0|1}]
slow_query_log_file = /var/log/mysql/mariadb-slow.log
long_query_time = 10
#log_slow_rate_limit    = 1000
#log_slow_verbosity = query_plan

#log-queries-not-using-indexes
#log_slow_admin_statements
#
# The following can be used as easy to replay backup logs or for replication.
# note: if you are setting up a replication slave, see README.Debian about
#       other settings you may need to change.
#server-id      = 1
#report_host        = master1
#auto_increment_increment = 2
#auto_increment_offset  = 1
#log_bin            = /var/log/mysql/mariadb-bin
#log_bin_index      = /var/log/mysql/mariadb-bin.index
# not fab for performance, but safer
#sync_binlog        = 1
expire_logs_days    = 10
max_binlog_size         = 100M
# slaves
#relay_log      = /var/log/mysql/relay-bin
#relay_log_index    = /var/log/mysql/relay-bin.index
#relay_log_info_file    = /var/log/mysql/relay-bin.info
#log_slave_updates
#read_only
#
# If applications support it, this stricter sql_mode prevents some
# mistakes like inserting invalid dates etc.
sql_mode        = ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
#
# * InnoDB
#
# InnoDB is enabled by default with a 10MB datafile in /var/lib/mysql/.
# Read the manual for more InnoDB related options. There are many!
default_storage_engine  = InnoDB
# you can't just change log file size, requires special procedure
#innodb_log_file_size   = 50M
innodb_buffer_pool_size = 256M
innodb_log_buffer_size  = 8M
innodb_open_files   = 400
innodb_io_capacity  = 400
innodb_flush_method = O_DIRECT

innodb_file_per_table=ON
innodb_large_prefix=ON
innodb_file_format=Barracuda
#
# * Security Features
#
# Read the manual, too, if you want chroot!
# chroot = /var/lib/mysql/
#
# For generating SSL certificates I recommend the OpenSSL GUI "tinyca".
#
# ssl-ca=/etc/mysql/cacert.pem
# ssl-cert=/etc/mysql/server-cert.pem
# ssl-key=/etc/mysql/server-key.pem

#
# * Galera-related settings
#
[galera]
# Mandatory settings
#wsrep_on=ON
#wsrep_provider=
#wsrep_cluster_address=
#binlog_format=row
#default_storage_engine=InnoDB
#innodb_autoinc_lock_mode=2
#
# Allow server to accept connections on all interfaces.
#
#bind-address=0.0.0.0
#
# Optional setting
#wsrep_slave_threads=1
#innodb_flush_log_at_trx_commit=0

[mysqldump]
quick
quote-names
max_allowed_packet  = 16M

[mysql]
#no-auto-rehash # faster start of mysql but no tab completion

[isamchk]
key_buffer      = 16M

#
# * IMPORTANT: Additional settings that can override those from this file!
#   The files must end with '.cnf', otherwise they'll be ignored.
#
!includedir /etc/mysql/conf.d/

Konfiguration Apache

Unser Apache bekommt die Datei /mnt/docker/000-default.conf vorgesetzt. Sie liegt ebenfalls im Verzeichnis mit unseren anderen Dateien.

UseCanonicalName Off

ServerAdmin patrick.froch@easysolutionsit.de

<VirtualHost 172.0.0.81>
    VirtualDocumentRoot /mnt/easy.Projekte/%1/%2/%3/vhosts/%4/web

    LogLevel warn
    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    <Directory />
        Options +FollowSymLinks +ExecCGI
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>

Hier passiert die ganze Magie und zwar in der Zeile VirtualDocumentRoot /mnt/projects/%1/%2/vhosts/%4/web. Diese sagt unserem Server, in welchem Verzeichnis die Daten liegen und nutzt dafür URL-Fragmente. Jede Zahl wird durch das entsprechende Fragment ersetzt. Haben wir die URL http://kunde01.projekt01.cto4/. wird %1 durch kunde01 ersetzt, %2 durch projekt01 und %3 durch cto4. Der Server sucht also im Pfad /mnt/projects/kunde01/projekt01/vhosts/cto4/web, nach den Dateien. Also im web-Verzeichnis unserer Contao-Installation. Wir können also jetzt beliebig viele Kunden und Projekte anlegen. Dies kann auch sehr nützlich sein, um die einzelnen Schritte dieses Kurses durchzuarbeiten.

In neueren Contaoversionen, wurde das Verzeichnis web durch public ersetzt. Da ich aber Erweiterungen in verschiedenen Versionen betreue, habe ich nach eine möglichst allegemeingültige Lösung gesucht. Das web-Verzeichnis funktioniert und Contao 4 und 5 gleichermasen. Wenn Ihr nur neuere Contaoversionen einsetzt, solltet Ihr web durch public ersetzten.

Konfiguration PHP

Auch die Datei /mnt/docker/custom.ini liegt im Verzeichnis mit den anderen Dateien. Es werden nur einige Einstellungen angepasst, der Rest kommt aus der php.ini des Images.

[PHP]

;;;;;;;;;;;;;;;;;;;
; Resource Limits ;
;;;;;;;;;;;;;;;;;;;

; Maximum execution time of each script, in seconds
; http://php.net/max-execution-time
; Note: This directive is hardcoded to 0 for the CLI SAPI
max_execution_time = 120

; Maximum amount of time each script may spend parsing request data. It's a good
; idea to limit this time on productions servers in order to eliminate unexpectedly
; long running scripts.
; Note: This directive is hardcoded to -1 for the CLI SAPI
; Default Value: -1 (Unlimited)
; Development Value: 60 (60 seconds)
; Production Value: 60 (60 seconds)
; http://php.net/max-input-time
max_input_time = 60

; How many GET/POST/COOKIE input variables may be accepted
max_input_vars = 256

; Maximum amount of memory a script may consume (128MB)
; http://php.net/memory-limit
memory_limit = 512M

;;;;;;;;;;;;;;;;;
; Data Handling ;
;;;;;;;;;;;;;;;;;
; Maximum size of POST data that PHP will accept.
; Its value may be 0 to disable the limit. It is ignored if POST data reading
; is disabled through enable_post_data_reading.
; http://php.net/post-max-size
post_max_size = 64M

;;;;;;;;;;;;;;;;
; File Uploads ;
;;;;;;;;;;;;;;;;
; Maximum allowed size for uploaded files.
; http://php.net/upload-max-filesize
upload_max_filesize = 64M

; Maximum number of files that can be uploaded via a single request
max_file_uploads = 20

;;;;;;;;;;;;;;;;;;;
; Module Settings ;
;;;;;;;;;;;;;;;;;;;

[Date]
; Defines the default timezone used by the date functions
; http://php.net/date.timezone
date.timezone = Europe/Berlin

;;;;;;;;;;;;;;;;;;;
; xdebug Settings ;
;;;;;;;;;;;;;;;;;;;

; xdebug path
zend_extension_debug = "/usr/local/lib/php/extensions/no-debug-non-zts-20210902/xdebug.so"
zend_extension = "/usr/local/lib/php/extensions/no-debug-non-zts-20210902/xdebug.so"


; New Xdebug Docker configuration
; https://xdebug.org/docs/upgrade_guide
; https://xdebug.org/docs/all_settings#mode
xdebug.mode=develop,coverage,debug
; https://xdebug.org/docs/step_debug
; https://xdebug.org/docs/all_settings#discover_client_host
xdebug.discover_client_host=true
; https://xdebug.org/docs/all_settings#client_discovery_header
; xdebug.client_discovery_header = ""
; https://xdebug.org/docs/all_settings#client_host
xdebug.client_host=host.docker.internal
; https://xdebug.org/docs/all_settings#client_port
xdebug.client_port=9003
; https://xdebug.org/docs/all_settings#start_with_request
xdebug.start_with_request=yes
; https://xdebug.org/docs/all_settings#var_display_max_data
xdebug.var_display_max_data=512
; https://xdebug.org/docs/all_settings#var_display_max_depth
xdebug.var_display_max_depth=3
; https://xdebug.org/docs/all_settings#var_display_max_children
xdebug.var_display_max_children=128
; https://xdebug.org/docs/all_settings#cli_color
xdebug.cli_color=1
; https://xdebug.org/docs/all_settings#show_local_vars
xdebug.show_local_vars=0
; https://xdebug.org/docs/all_settings#dump_globals
xdebug.dump_globals=true
; https://xdebug.org/docs/all_settings#dump_once
xdebug.dump_once=true
; https://xdebug.org/docs/all_settings#dump_undefined
xdebug.dump_undefined=false;
; https://xdebug.org/docs/all_settings#dump.*
xdebug.dump.SERVER=REMOTE_ADDR,REQUEST_METHOD
xdebug.dump.GET=*
xdebug.dump.POST=*
; https://xdebug.org/docs/all_settings#file_link_format
;xdebug.file_link_format =
; https://xdebug.org/docs/all_settings#filename_format
;xdebug.filename_format = ...%s%n
; https://xdebug.org/docs/all_settings#max_stack_frames
xdebug.max_stack_frames=-1
; https://xdebug.org/docs/all_settings#show_error_trace
xdebug.show_error_trace=0
; https://xdebug.org/docs/all_settings#show_exception_trace
xdebug.show_exception_trace=0

Starten

Wenn wir nun im Verzeichnis mit unseren Dockerdateien den folgenden Befehl eingeben, sollte unsere Entwicklungsumgebung starten:

docker-compose up -d

Will man die Ausgabe auch im Betrieb sehen, lässt man den Parameter -d weg. In diesem Fall darf man aber das Terminal nicht schließen, da sonst auch der Server beendet wird. Will man ihn in diesem Modus anhalten, reicht ein Durck auf Strg+c. Wichtig ist, dass man nach dem Betrieb immer den Befehl docker-compose down verwendet, sonst kann es bei nächsten Start Probleme geben, wenn das virtuelle Netzwerk nicht richtig beendet wurde.

Testen

Nun legen wir testweise eine HTML-Datei im Verzeichnis unseres ersten Hosts an /mnt/projects/kunde01/projekt01/vhosts/cto4/web/index.html. Diese Datei dient nur als Test und muss wieder gelöscht werden, wenn wir Contao installieren.

<!DOCTYPE html>
<html lang="de">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Docker Test</title>
  </head>
  <body>
        <h1>Mein erster Docker Test</h1>
  </body>
</html>

Wenn wir nun im Browser die URL unseres ersten Hosts aufrufen (http://kunde01.projekt01.cto4/), sieht man, dass es nicht funktioniert. Dies liegt daran, dass unser Host unbekannt ist, er hat ja keinen DNS-Eintrag. Unter Linux reicht ein Eintrag am Ende der Datei /etc/hosts mit folgendem Inhalt:

172.0.0.81  kunde01.projekt01.cto4

(Unter Windows heißt die Datei %windir%\system32\drivers\etc/HOSTS.)

Wir haben in der Datei docker-compose.yml festgelegt, dass der Server die IP 172.0.0.81 haben soll und in der Datei 000-default.conf dem Server gesagt, dass er auf diese IP lauschen soll. Wenn wir es nun noch einmal versuchen, sollte unsere Datei angezeigt werden.

Bevor wir uns nun um die Installation unseres ersten Contaos kümmern, ist ein guter Zeitpunkt die Testdatei /mnt/projects/kunde01/projekt01/vhosts/cto4/web/index.html wieder zu löschen.