HTTPS mit Docker in lokaler Entwicklungsumgebung

Eine lokale Entwicklungsumgebung für ein Projekt aufzusetzen sollte für uns Entwickler kein Neuland mehr sein und bereits zum Standard gehören.
Ziel ist stets, dass sich die lokale Umgebung nahezu identisch zum Produktivsystem verhält. Nur so kann gewährleisten werden, dass beim Migrieren von Änderungen keine ungewollten Nebeneffekte auftreten.

Vielleicht hattet ihr auch schon den Fall, dass ihr gerne für eure lokale Domain HTTPS aktivieren wolltet. Ich stand erst vor Kurzem vor diesem Problem, da ich in einem Projekt alle Möglichkeiten von HTTP2 testen wollte. Dazu ist eine sichere Verbindung mittels SSL von Nöten.

Bei selbst-signierten Zertifikaten spielen oft die Browser nicht mit und zeigen trotz Zertifikat eine unsichere Verbindung und demnach ein rotes Schlosssymbol an. Wie ihr das lösen könnt, habe ich euch in den folgenden 7 Schritten erläutert.


  1. Ordner für SSL Keys anlegen
  2. eigene Certificate Authority erstellen
  3. Zertifizierungsstelle im Browser hinterlegen
  4. Self Signed Key anlegen mit entsprechender Domain
  5. Domain unter /etc/hosts einfügen
  6. Datei docker-compose.yml anpassen
  7. Nginx Konfiguration anpassen



1) Ordner für SSL Keys anlegen

Für meine Projekte nutze ich immer einen docker Ordner, welcher alle Docker-bezogenen Dateien enthält. Hier liegt der neu erstellte Ordner ssl.

2) eigene Certificate Authority erstellen

Um ein eigenes Zertifikat zu signieren, benötigt man eine eigene Zertifizierungsstelle. Diese kann mittels openssl im Terminal angelegt werden. Zunächst erstellt man den privaten Schlüssel und vergibt ein Passwort (wird später noch benötigt).
Navigiere also zum erstellen ssl-Ordner und führe folgenden Command aus:

openssl genrsa -des3 -out SDWebDev.key 2048

SDWebDev entspricht hier dem Kürzel meiner Zertifizierungsstelle.

Als nächstes legt man für diesen Key ein Root-Zertifikat an. Hier werden einige Attribute wie Organisation, Einheit und Common Name abgefragt.

openssl req -x509 -new -nodes -key SDWebDev.key -sha256 -days 825 -out SDWebDev.pem

3) Zertifizierungsstelle im Browser hinterlegen

Damit die eigene Zertifizierungsstelle später anerkannt wird und ein grünes Schlosszeichen erscheint, muss der Key im jeweiligen Browser importiert werden.
Ich habe es jeweils für Chrome und Firefox hinterlegt.

Diese Möglichkeit findest du im Browser unter Einstellung -> Datenschutz & Sicherheit -> Zertifikate verwalten.
Navigiere hier zum Tab Zertifizierungsstellen und importiere das erstellte Root-Zertifikat (in meinem Fall SDWebDev.pem).

Es erscheint ein Popup, in dem man festlegen kann, für welche Bereiche das Zertifikat zulässig ist.

Chrome Einstellungen Datenschutz und Sicherheit
Chrome - Zertifizierungsstelle importieren

Nach erfolgreichem Import sollte deine eigene Zertifizierungsstelle in der Liste enthalten sein.

4) Self Signed Key anlegen mit entsprechender Domain

Als nächstes musst du einen Self Signed Key für das Projekt anlegen.
Schritt 1 ist hier das Erstellen eines privaten Schlüssels

openssl genrsa -out myproject.key 2048

Ich habe für Key und Zertifikat jeweils den Projektnamen myproject gewählt. Hier kannst du selbst wählen, wie du sie benennen möchtest.

Schritt 2 ist das Anlegen eines Certificate Signing Requests (CSR) mit folgendem Command:

openssl req -new -key myproject.key -out myproject.csr -subj '/CN=SDWebDev/O=SDWebDev/OU=IT' -extensions SAN -reqexts SAN -config <( \
   printf "[SAN]\nsubjectAltName=DNS:myproject.local\n[dn]\nCN=SDWebDev\n[req]\ndistinguished_name = dn\n[EXT]\nauthorityKeyIdentifier=keyid,issuer\nbasicConstraints=CA:FALSE\nsubjectAltName=DNS:myproject.local\nkeyUsage=digitalSignature,nonRepudiation,keyEncipherment,dataEncipherment\nextendedKeyUsage=serverAuth")

Achte hier jeweils auf myproject.local. Diesen Wert solltest du mit deiner gewählten Domain ersetzen. Die Werte O und OU beschreiben jeweils deine Organisation und die Organisationseinheit.

Die Extension SAN ist hierbei sehr wichtig, damit es reibungslos im Chrome Browser funktioniert.
Für diese Extension legen wir noch die Datei myproject.ext an, welche ein paar wichtige Attribute wie die alternate names enthält.

authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature,nonRepudiation,keyEncipherment,dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = myproject.local

Der letzte Schritt ist das Erstellen des Keys mit der zuvor selbst angelegten Zertifizierungsstelle.

openssl x509 -req -in myproject.csr -out myproject.crt -CA SDWebDev.pem -CAkey SDWebDev.key -CAcreateserial \
  -sha256 -days 3650 \
-extfile myproject.ext

5) Domain unter /etc/hosts einfügen

Damit die gewünschte Domain (in meinem Fall myproject.local) erkannt wird, muss ein Alias in der Datei /etc/hosts angelegt werden

127.0.0.1       myproject.local

6) Datei docker-compose.yml anpassen

Der SSL Ordner mit deinen Keys muss nun Nginx-Container von Docker gelesen werden können. Dazu reicht es ein Volume anzulegen, welches den Ordner docker/ssl referenziert.

Ebenso müssen die Ports 80 und 443 freigegeben sein.
Hier seht ihr meine beispielhafte Konfiguration:

# docker-compose.yaml
version: '3.3'

services:
  nginx:
    image: nginx:1.11.8-alpine
    ports:
      - "80:80"
      - "443:443"
    environment:
      APPLICATION_ENV: development
      VIRTUAL_HOST: myproject.local
    volumes:
      - ./myproject:/var/www/html
      - ./docker/nginx/config/site.conf:/etc/nginx/conf.d/default.conf
      - ./docker/ssl:/ssl
    links:
      - php

  php:
    build: ./docker/php
    ports:
      - "9000:9000"
    environment:
      APPLICATION_ENV: development
    volumes:
      - ./myproject:/var/www/html

7) Nginx Konfiguration anpassen

Damit HTTPS interpretiert werden kann, muss ebenfalls die Nginx-Konfiguration eurer Seite angepasst werden.
Das könnte dann so aussehen:

server {
    listen 80;
    listen [::]:80;
    server_name myproject.local;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name myproject.local;

    index  index.php index.html index.htm;
    root /var/www/html/;

    ssl on;
    ssl_certificate /ssl/myproject.crt;
    ssl_trusted_certificate /ssl/myproject.crt;
    ssl_certificate_key /ssl/myproject.key;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;

    ssl_buffer_size 1369;
    ssl_session_tickets on;

    ssl_session_timeout 60m;
    ssl_session_cache shared:SSL:10m;

    charset utf-8;
    access_log /var/log/nginx/myproject-access.log;
    error_log /var/log/nginx/myproject-error.log;

    proxy_force_ranges on;

    tcp_nopush         on;
    tcp_nodelay        on;

    gzip on;
    gzip_disable "msie6";
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_min_length 256;
    gzip_types text/plain application/javascript application/json application/rss+xml application/vnd.ms-fontobject application/x-font-ttf application/xhtml+xml application/xml font/opentype image/svg+xml image/x-icon text/css text/x-component text/xml application/atom+xml;

    etag off;
    chunked_transfer_encoding off;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass php:9000;
        fastcgi_param   APPLICATION_ENV  development;
        include /etc/nginx/fastcgi_params;
    }
}

Finish

Wenn ihr diese 7 Schritte durchgeführt habt, könnt ihr wie gewohnt eure Docker Container bauen und Starten.

docker-compose build
docker-compose up

Grünes Schlosszeichen in Chrome

Testet, ob SSL nun aktiv ist, indem ihr eure lokale Webseite öffnet. In meinem Fall https://myproject.local

TADA !!! Es erscheint ein grünes Schlosssymbol im Browser. Chrome und Firefox erkennen das Zertifikat als gültig an.

Ich hoffe ich konnte euch mit meiner Anleitung einiges an Recherche ersparen.
Viel Spaß damit !

Sulu Sitemap mit Symfony Routen erweitern

Sulu ist ein Content-Management-System, welches mit Hilfe des PHP-Frameworks Symfony erstellt wurde. Das Augenmerk bei Sulu liegt in der leichten Bedienbarkeit für den Entwickler.
Wer oft mit Symfony arbeitet wird merken, wie schnell sich das System an die eigenen Anforderungen anpassen lässt. Dazu müssen keine komplexen Plugins geschrieben werden. Es greifen die herkömmlichen Symfony Standards.

So verhält es sich auch bei der Erstellung einer Sitemap. Sulu bietet bereits eine Schnittstelle, um die Sitemap zu generieren.
Es werden alle Seiten ausgelesen, die im Adminbereich über das CMS System angelegt wurden.

Über folgenden Command kann eine Sitemap initial generiert werden.

bin/websiteconsole sulu:website:dump-sitemap

Im Produktivsystem können Optionen mit übergeben werden, wie z.B.

bin/websiteconsole sulu:website:dump-sitemap --https --env=prod

Meine Ausgangssituation

In meinem Projekt habe ich nun den folgenden Fall:

  • Die meisten Seiten werden direkt über das Sulu CMS im Adminbereich erstellt (z.B. Rezept-Seiten). Es dient als typischer Blog. Das Template ist pro Seitentyp fest definiert.
  • Es gibt jedoch auch Seiten, die direkt in Symfony gepflegt werden, da sie separate Templates oder eine spezielle Logik im Controller besitzen.

Diese Seiten werden standardmäßig in Symfony über die routes.yaml definiert. In Sulu erfolgt dieses unter der Datei routes_websites.yaml.
Meine Datei sieht wie folgt aus:

# config/routes_website.yaml

impressum:
    path: /impressum
    controller: App\Controller\Website\StaticPageController::impressumAction

dataprotection:
    path: /datenschutz
    controller: App\Controller\Website\StaticPageController::dataprotectionAction

contact:
    path: /kontakt
    controller: App\Controller\Website\StaticPageController::contactAction

Diese Routen fehlen bislang in der Sitemap.
Es ist aber sehr einfach die Sulu Sitemap zu erweitern. Es benötigt lediglich 2 Schritte, wobei Schritt 1 optional ist.

Schritt 1 – Konfiguration anpassen (optional)

Ihr könnt über den Parameter sulu_website.sitemap.dump_dir steuern, wo die Sitemap Dateien abgelegt werden sollen. Ich habe hier den public Ordner gewählt.

# /config/services.yml
parameters:
    ...
    sulu_website.sitemap.dump_dir: "%kernel.project_dir%/public/sitemaps"

Schritt 2 – Eigene Provider Klasse anlegen

Als nächstes erstellt ihr eine eigene Provider Klasse.
Diese muss das Interface Sulu\Bundle\WebsiteBundle\Sitemap\SitemapProviderInterface einbinden und die nötigen Methoden implementieren.

Um meine statischen Routen auszulesen, habe ich eine neue Klasse SitemapProvider erstellt. In dem Array $routeNames liste ich die Namen meiner Routen auf, die in die Sitemap aufgenommen werden sollen (siehe Abschnitt: Meine Ausgangssituation)

Hier meine vollständige Klasse:

Das war es auch schon. Die Sitemap kann nun aufgerufen werden unter http://localhost:8080/sitemap.xml

Beim Aufruf werdet ihr sehen, dass Sulu die Sitemap in kleinere Dateien aufteilt. Hier findet ihr auch eure neu angelegte Sitemap mit dem Alias mySitemap , den ihr in der Provider Klasse definiert habt.

<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <sitemap>
        <loc>http://localhost/sitemaps/mySitemap-1.xml</loc>
    </sitemap>
    <sitemap>
        <loc>http://localhost/sitemaps/pages-1.xml</loc>
    </sitemap>
</sitemapindex>

Die Pfade zu den Locations löst Sulu automatisch auf. Darum müsst ihr euch nicht kümmern.
Wenn ihr nun z.B. http://localhost/sitemaps/mySitemap-1.xml aufruft, seht ihr eure definierten statischen Symfony Routen mit den jeweiligen Änderungswerten. Das Auslesen dieser Werte bestimmt ihr selbst in eurer Provider Klasse.

<urlset xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
        <loc>http://localhost/impressum</loc>
        <lastmod>2020-06-10</lastmod>
    </url>
    <url>
        <loc>http://localhost/datenschutz</loc>
        <lastmod>2020-06-10</lastmod>
    </url>
    <url>
        <loc>http://localhost/kontakt</loc>
        <lastmod>2020-06-10</lastmod>
    </url>
</urlset>

That’s it !
Nun könnt ihr eure Sitemap verwenden … sie in die robots.txt eintragen, sie bei Google einreichen etc.

Ich hoffe dieser Blog Post ist für einige eine kleine Hilfestellung.

Viel Spaß damit und viele Grüße
Stefanie

In nur 3 Schritten HTTP Basic Authentication in Symfony integrieren – ohne User Entity

Basic Auth Login Dialog

Manchmal ist es von Nöten, dass man eine bestimmte Route in einer Symfony Anwendung mit einem Passwort schützen möchte. Symfony bietet von Haus aus sehr viele Möglichkeiten ein User Management einzubinden.

Bei mir gab es jedoch den Fall, dass ich nur eine bestimmte Route mit einem Passwort schützen wollte. Und dieses auch nur für einen kurzen Zeitraum. Meine restliche Anwendung benötigte bisher keine Rechte und ich wollte für diesen temporären Einsatz den Aufwand so gering wie möglich halten.

Zum Glück bietet Symfony auch für diesen Fall eine Lösung. Die Antwort lautet Memory User Provider. Dieser Provider gehört zu den Standard Authentifizierungs-Providern, die Symfony bereitstellt.

In der Regel sollte der Memory User Provider eher sparsam eingesetzt werden, da er nicht flexibel genug ist, um ein User Management zu integrieren. Aber bei meinem Sonderfall war es genau passend.

Du benötigst nur 3 Schritte, um eine statische Route mit einem Login und einem Passwort zu schützen. Los geht’s !

Schritt 1: Symfony Security Bundle installieren

Falls du das Security Bundle nicht bereits in deiner Anwendung nutzt, musst du es zunächst installieren.

Falls du Symfony Flex nutzt, muss du nichts weiter tun, als das Bundle über Composer zu laden.

Security Bundle über Composer laden

Schritt 2: Password kodieren

Als nächstes musst du das spätere Password für deinen User kodieren. Dazu bietet dir Symfony bereits einen Command.
Gehe in die Console und führe folgenden Task aus

Passwort kodieren

Den Wert, den du nach Ausführen des Commands erhälst, benötigst du für den nächsten Schritt.

Schritt 3: security.yaml anpassen

Im letzten Schritt muss die Konfigurationsdatei unter config/packages/security.yaml angepasst werden.

Hier trägst du nun den Encoder, die zu verschlüsselne Route und deine User mit dem in Schritt 2 gewählten Passwort ein.

Der Encoder wird zwingend benötigt. Andernfalls wird ein Fehler geworfen. Hier reicht der Standard Encoder mit dem Wert auto.

Unter dem Attribut providers gibst du dein generiertes Passwort ein und teilst es dem User zu.

Zuletzt musst du noch deine Route in der Firewall definieren. Hier ist es /my-protected-route. Als Provider bekommt die deinen oberhalb definierten Provider zugewiesen

Das war es schon. Wenn du nun deine Seite aufrufst, solltest du einen Login-Dialog erhalten. Nur dein oben definierter User mit dem entsprechenden Password kann zugreifen.

Login Dialog erscheint beim Aufrufen der Route

So das war es auch schon. Ich denke nicht mal 5 Minuten oder?

Schnelles Pimcore Setup mit Docker

Ich experimentiere momentan mit dem Open-Source PIM/MDM Pimcore und die Erstellung und Integration von selbstgeschriebenen Plugins. Damit nicht unnötig Zeit für das Setup verschwendet wird, habe ich mir eine Dockerkonfiguration für die lokale Entwicklung angelegt, die mittels docker-compose gestartet werden kann.

Doch zunächst – Was ist Pimcore?

Pimcore ist eine Kombination aus Product-Information-Management (PIM) und Master-Data-Management (MDM). Auf deutsch bedeutet das soviel, dass Produkte zentral in einem System verwaltet werden können. Ebenfalls Stammdaten wie Mitarbeiter, Kunden, Hersteller oder Lieferanten können über Pimcore gepflegt werden.

Wer einen Online-Shop betreiben möchte, kann dieses ebenfalls mit Pimcore erledigen. Dazu gibt es die Erweiterung CoreShop, die viele Funktionalitäten abdeckt.

Was Pimcore unter anderem auszeichnet, ist die benutzerdefinierte Modellierung von Inhalten. Neben bereits vorgefertigten Inhaltstypen können eigene Datenstrukturen angelegt und definiert werden – ganz einfach in der grafischen Adminoberfläche.

Genug Intro – los geht’s mit Docker

Die zentrale Einstiegsstelle ist die docker-compose.yml Datei. Diese liegt direkt im Hauptprojektverzeichnis. Hier werden die Services defniert, die für Pimcore benötigt werden. Das sind ein Webserver (hier Nginx), eine Datenbank (hier MySQL) und PHP.

version: '3.3'

services:
    nginx:
        image: nginx:latest
        ports:
            - "8080:80"
        volumes:
            - .:/var/www/html
            - ./docker/nginx/config/site.conf:/etc/nginx/conf.d/default.conf
        links:
            - php
        networks:
            - pimcorenet

    mysql:
        image: mysql:5.7
        restart: always
        ports:
            - "3306:3306"
        command: --init-file /data/application/init.sql
        volumes:
            - ./docker/mysql_init.sql:/data/application/init.sql
            - ./data/mysql:/var/lib/mysql
            - .:/var/www/html
        environment:
            MYSQL_ROOT_PASSWORD: pimcore
            MYSQL_DATABASE: pimcore
            MYSQL_USER: pimcore
            MYSQL_PASSWORD: pimcore
        networks:
            - pimcorenet

    php:
        build: ./docker/php
        volumes:
            - .:/var/www/html
        links:
            - mysql:mysql
        depends_on:
            - mysql
        networks:
          - pimcorenet

networks:
    pimcorenet:

Für die Container Nginx und MySQL benutze ich fertige Docker Images. Diese können dann von außen über Parameter konfiguriert werden. Bei dem MySQL Image wird in dem Command eine SQL Datei aufgerufen, welche dafür sorgt, dass der User pimcore existiert.
Den kompletten Source Code findest du in meinem Repository unter: https://github.com/StefanieD/pimcore-docker-setup

Für den PHP Container nutze ich ein angepasstes Dockerfile. Dieses liegt unter ./docker/php und wird über den build Parameter in der Servicekonfiguration referenziert.

Oft reichen fertige PHP Docker Images nicht aus, da bestimmte Erweiterungen nachinstalliert werden müssen. Aus diesem Grund erweitert das Dockerfile ein existierendes Image für php7.2-fpm

FROM php:7.2-fpm

# install git
RUN apt-get update && \
    apt-get install -y --no-install-recommends git

#install some base extensions
RUN apt-get install -y \
    zlib1g-dev \
    zip \
    libpng-dev \
    exiftool \
    libfreetype6-dev \
    libjpeg62-turbo-dev \
    libmcrypt-dev \
    libicu-dev \
    libpq-dev \
    libxpm-dev \
    libvpx-dev \
    mariadb-client \
    libxml2-dev

RUN docker-php-ext-install -j$(nproc) \
    zip \
    exif \
    bcmath \
    intl \
    pcntl \
    mysqli \
    pdo \
    pdo_mysql \
    pdo_pgsql \
    mbstring \
    soap \
    opcache \
    iconv

# Install Imagick
RUN apt-get update && apt-get install -y \
    libmagickwand-dev --no-install-recommends \
    && pecl install imagick \
    && docker-php-ext-enable imagick

# Install Composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer --version

Und zu guter Letzt – Docker starten

Um die Anwendung nun zu starten, braucht man nichts anderes tun als im Terminal zu seinem Projektordner zu wechseln und folgende Befehle auszuführen

docker-compose build 

zum Bauen der Images und

docker-compose up -d

Beim ersten Aufruf werden die Images vom Docker Repository heruntergeladen und es kann etwas länger dauern. Später sind diese jedoch im Cache und müssen nicht erneut geladen werden.

Nachdem die Container gestartet sind, müssen noch einige Befehle manuell durchgeführt werden.
Pimcore muss installiert, ein initialer Admin User angelegt und die Datenbank konfiguriert werden.
Die Anleitung dazu findest du hier https://github.com/StefanieD/pimcore-docker-setup

Nachdem diese Schritte erfolgreich durchgeführt wurden, ist die Anwendung nun unter http://localhost:8080 erreichbar.
Den Adminbereich erreicht man unter http://localhost:8080/admin.

Mit dem zuvor angelegten Benutzer admin – admin könnt ihr euch in Pimcore anmelden.

Viel Spaß beim Experimentieren !

Quellen

  • https://pimcore.com/de
  • https://blog.perfion.com/de/was-ist-der-unterschied-zwischen-pim-und-mdm-und-warum-sie-sich-interessieren-sollten
  • https://basilicom.de/de/pimcore
  • https://www.mds.eu/blog/pim/was-ist-pimcore
  • https://divante.co/blog/pimcore-special-agency-point-view/

Elasticsearch NumberFormatException

Some time ago I came across a really annoying error in Elasticsearch.
The error log was something like this:

SearchPhaseExecutionException[Failed to execute phase [query], all shards failed; shardFailures {[_9x6xIKhTXK7y_jqC92YIQ][index][0]: ElasticicsearchException[java.lang.NumberFormatException:Invalid shift value in prefixCoded bytes (is encoded value really an INT?)]; nested:UncheckedExecutionException[java.lang.NumberFormatException: Invalid shift value in prefixCoded bytes (is encoded value really an INT?)]; nested: NumberFormatException[Invalid shift value in prefixCoded bytes (is encoded value really an INT?)]; }

It was driving me nuts to find out what the exact problem was. I searched several hours in the internet for an answer until I found a helpful post in a forum.
The actual problem is that there is a field in your Elasticsearch index, which have different mapping settings in different types.
I.e. you have the type car and the type driver and both are types of the index company. Both tpes have an ID. But one is declare as integer value and the other is a string.
Elasticsearch doesn’t make a difference between fields in different types but with the same name in the same index. So check your mappings to solve the problem.

But be aware. After I changed my mappings I still got the error message. After changing the values I also needed to clear my index to delete all old documents.
For deleting all old cached documents I made an optimize POST request to elasticsearch:

http://elasticsearch:9200/[index]/_optimize?max_num_segments=1&wait_for_merge=false/

The params are really important otherwise the documents will not be deleted. After that everything worked fine again.

I hope I could help you with my post and save you some valuable time of research:)

Starting my first blog

Hey out there. My name is Stefanie and I’ve been working as a PHP / fullstack developer for more than 3 years now.

Recently I read an article about best practices for improving my developing skills.

One of the recommendations was to write a blog about developing problems which comes up in the daily business. So I decided to follow this approach and startet to write a blog. In this blog I want to give you some practical helps and share my knowledge I’ve gained the last years as fullstack developer.

Have fun and don’t hesitate to leave me a comment.

Regards Stefanie

© 2020 Stefanie Drost

Theme von Anders Norén↑ ↑