diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 93c274a3c4308d5dbd7a77da7649c5add8ef7414..deb76f5a1413ac3fb4704cd8c31d9917575e389e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -5,6 +5,7 @@ stages:
   - quality
   - bundle
   - release
+  - build
   - deploy
 
 php-dependencies:
@@ -151,3 +152,16 @@ documentation:
   only:
     changes:
       - docs/**/*
+
+docker:
+  stage: build
+  trigger:
+    include: docker/production/.gitlab-ci.yml
+    strategy: depend
+  variables:
+    PARENT_PIPELINE_ID: $CI_PIPELINE_ID
+  only:
+    refs:
+      - develop
+    variables:
+      - $CI_PROJECT_NAMESPACE == "adaures"
diff --git a/docker-compose.yml b/docker-compose.yml
index 667b511edfe816135c498859102a9775936dacf8..40109536f4ed61ac30c001e686b892344c4937c9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,7 +7,7 @@ services:
   app:
     build:
       context: .
-      dockerfile: Dockerfile
+      dockerfile: docker/development/Dockerfile
     container_name: app
     command: /bin/sh -c "crontab ./crontab && cron && service cron reload && php spark serve - 0.0.0.0"
     ports:
diff --git a/Dockerfile b/docker/development/Dockerfile
similarity index 100%
rename from Dockerfile
rename to docker/development/Dockerfile
diff --git a/docker/production/.gitlab-ci.yml b/docker/production/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5c9e8b22e3103375b74988bc7534a2dc1d566271
--- /dev/null
+++ b/docker/production/.gitlab-ci.yml
@@ -0,0 +1,20 @@
+stages:
+  - build
+
+docker-build-rolling:
+  stage: build
+  image:
+    name: gcr.io/kaniko-project/executor:debug
+    entrypoint: [""]
+  variables:
+    TAG: $CI_COMMIT_BRANCH
+  script:
+    - cp ${DOCKER_HUB_CONFIG} /kaniko/.docker/config.json
+    - /kaniko/executor --context . --dockerfile docker/production/web-server/Dockerfile --destination ${DOCKER_IMAGE_WEB_SERVER}:${TAG}
+    - /kaniko/executor --context . --dockerfile docker/production/app/Dockerfile --destination ${DOCKER_IMAGE_APP}:${TAG}
+  needs:
+    - pipeline: $PARENT_PIPELINE_ID
+      job: bundle
+  only:
+    refs:
+      - develop
diff --git a/docker/production/app/Dockerfile b/docker/production/app/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..f764a3f9a3e174cbab298a889eae2b3d2dd53276
--- /dev/null
+++ b/docker/production/app/Dockerfile
@@ -0,0 +1,40 @@
+FROM docker.io/alpine:3.13 AS ffmpeg-downloader
+
+RUN apk add --no-cache curl && \
+    curl https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \
+    tar -xJf ffmpeg.tar.xz && \
+    mv ffmpeg-5.0.1-amd64-static ffmpeg
+
+FROM docker.io/php:8.0-fpm-alpine3.13
+
+COPY docker/production/app/entrypoint.sh /entrypoint.sh
+
+COPY docker/production/app/uploads.ini /usr/local/etc/php/conf.d/uploads.ini
+
+RUN echo "* * * * * /usr/local/bin/php /opt/castopod/public/index.php scheduled-activities" > /crontab.txt && \
+    echo "* * * * 10 /usr/local/bin/php /opt/castopod/public/index.php scheduled-video-clips" >> /crontab.txt && \
+    echo "* * * * * /usr/local/bin/php /opt/castopod/public/index.php scheduled-websub-publish" >> /crontab.txt
+
+RUN apk add --no-cache libpng icu-libs freetype libwebp libjpeg-turbo libxpm ffmpeg && \
+    apk add --no-cache --virtual .php-ext-build-dep freetype-dev libpng-dev libjpeg-turbo-dev libwebp-dev zlib-dev libxpm-dev icu-dev && \
+    docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp --with-xpm && \
+    docker-php-ext-install gd intl mysqli exif && \
+    docker-php-ext-enable mysqli gd intl exif && \
+    apk del .php-ext-build-dep
+
+COPY castopod /opt/castopod
+COPY --from=ffmpeg-downloader /ffmpeg/ffmpeg /ffmpeg/ffprobe /ffmpeg/qt-faststart /usr/local/bin/
+
+RUN chmod 544 /entrypoint.sh && \
+    chmod 444 /crontab.txt && \
+    /usr/bin/crontab /crontab.txt
+
+WORKDIR /opt/castopod
+
+VOLUME /opt/castopod/public/media
+
+EXPOSE 9000
+
+ENTRYPOINT [ "sh", "-c" ]
+
+CMD [ "/entrypoint.sh" ]
diff --git a/docker/production/app/entrypoint.sh b/docker/production/app/entrypoint.sh
new file mode 100644
index 0000000000000000000000000000000000000000..2a1ecfa8c54861f42b0f994c18ff02b80016d501
--- /dev/null
+++ b/docker/production/app/entrypoint.sh
@@ -0,0 +1,150 @@
+#!/bin/sh
+
+if [ -z "${CP_BASEURL}" ]
+then
+	echo "CP_BASEURL must be set"
+	exit 1
+fi
+
+if [ -z "${CP_MEDIA_BASEURL}" ]
+then
+	echo "CP_MEDIA_BASEURL is empty, leaving empty by default"
+fi
+
+if [ -z "${CP_ADMIN_GATEWAY}" ]
+then
+	echo "CP_ADMIN_GATEWAY is empty, using default"
+	CP_ADMIN_GATEWAY="cp-admin"
+fi
+
+if [ -z "${CP_AUTH_GATEWAY}" ]
+then
+	echo "CP_AUTH_GATEWAY is empty, using default"
+	CP_AUTH_GATEWAY="cp-auth"
+fi
+
+if [ -z "${CP_ANALYTICS_SALT}" ]
+then
+	echo "CP_ANALYTICS_SALT is empty, this is mandatory, generate a new one with tr -dc \\!\\#-\\&\\(-\\[\\]-\\_a-\\~ </dev/urandom | head -c 64"
+	exit 1
+fi
+
+if [ -z "${CP_DATABASE_HOSTNAME}" ]
+then
+	echo "CP_DATABASE_HOSTNAME is empty, using default"
+	CP_DATABASE_HOSTNAME="mariadb"
+fi
+
+if [ -z "${CP_DATABASE_PREFIX}" ]
+then
+	echo "CP_DATABASE_PREFIX is empty, using default"
+	CP_DATABASE_PREFIX="cp_"
+fi
+
+if [ -z "${CP_DATABASE_NAME}" ]
+then
+	if [ -z "${MYSQL_DATABASE}" ]
+	then
+		echo "When CP_DATABASE_NAME is empty, MYSQL_DATABASE must be set"
+		exit 1
+	fi
+
+	echo "CP_DATABASE_NAME is empty, using mysql variable"
+	CP_DATABASE_NAME="${MYSQL_DATABASE}"
+fi
+
+if [ -z "${CP_DATABASE_USERNAME}" ]
+then
+	if [ -z "${MYSQL_USER}" ]
+	then
+		echo "When CP_DATABASE_USERNAME is empty, MYSQL_USER must be set"
+		exit 1
+	fi
+
+	echo "CP_DATABASE_USERNAME is empty, using mysql variable"
+	CP_DATABASE_USERNAME="${MYSQL_USER}"
+fi
+
+if [ -z "${CP_DATABASE_PASSWORD}" ]
+then
+	if [ -z "${MYSQL_PASSWORD}" ]
+	then
+		echo "When CP_DATABASE_PASSWORD is empty, MYSQL_PASSWORD must be set"
+		exit 1
+	fi
+
+	echo "CP_DATABASE_PASSWORD is empty, using mysql variable"
+	CP_DATABASE_PASSWORD="${MYSQL_PASSWORD}"
+fi
+
+if [ ! -z "${CP_REDIS_HOST}" ]
+then
+	echo "Using redis cache handler"
+	CP_CACHE_HANDLER="redis"
+	if [ -z "${CP_REDIS_PASSWORD}" ]
+	then
+		echo "CP_REDIS_PASSWORD is empty, using default"
+		CP_REDIS_PASSWORD="null"
+	else
+		CP_REDIS_PASSWORD="\"${CP_REDIS_PASSWORD}\""
+	fi
+
+	if [ -z "${CP_REDIS_PORT}" ]
+	then
+		echo "CP_REDIS_PORT is empty, using default"
+		CP_REDIS_PORT="6379"
+	fi
+
+	if [ -z "${CP_REDIS_DATABASE}" ]
+	then
+		echo "CP_REDIS_DATABASE is empty, using default"
+		CP_REDIS_DATABASE="0"
+	fi
+else
+	echo "Using file cache handler"
+	CP_CACHE_HANDLER="file"
+fi
+
+cat << EOF > /opt/castopod/.env
+app.baseURL="${CP_BASEURL}"
+app.mediaBaseURL="${CP_MEDIA_BASEURL}"
+EOF
+
+if [ "${CP_DISABLE_HTTPS}" == "1" ]
+then
+	echo "HTTPS redirection is disabled for test purpose, please enable it in production mode"
+	echo "app.forceGlobalSecureRequests=false" >> /opt/castopod/.env
+else
+	echo "HTTPS redirection is enabled by default (mandatory to federate with the fediverse), use CP_DISABLE_HTTPS=1 to disable it for test purpose"
+fi
+
+cat << EOF >> /opt/castopod/.env
+admin.gateway="${CP_ADMIN_GATEWAY}"
+auth.gateway="${CP_AUTH_GATEWAY}"
+
+analytics.salt="${CP_ANALYTICS_SALT}"
+
+database.default.hostname="${CP_DATABASE_HOSTNAME}"
+database.default.database="${CP_DATABASE_NAME}"
+database.default.username="${CP_DATABASE_USERNAME}"
+database.default.password="${CP_DATABASE_PASSWORD}"
+database.default.DBPrefix="${CP_DATABASE_PREFIX}"
+
+cache.handler="${CP_CACHE_HANDLER}"
+EOF
+
+if [ "${CP_CACHE_HANDLER}" == "redis" ]
+then
+	cat << EOF >> /opt/castopod/.env
+cache.redis.host="${CP_REDIS_HOST}"
+cache.redis.password=${CP_REDIS_PASSWORD}
+cache.redis.port=${CP_REDIS_PORT}
+cache.redis.database=${CP_REDIS_DATABASE}
+EOF
+fi
+
+echo "Using config:"
+cat /opt/castopod/.env
+
+/usr/sbin/crond -f /crontab.txt -L /dev/stdout &
+/usr/local/sbin/php-fpm
diff --git a/docker/production/app/uploads.ini b/docker/production/app/uploads.ini
new file mode 100644
index 0000000000000000000000000000000000000000..23b3c1cdf87ca6ca0ae34ba15c430029cf3116b3
--- /dev/null
+++ b/docker/production/app/uploads.ini
@@ -0,0 +1,5 @@
+file_uploads = On
+memory_limit = 512M
+upload_max_filesize = 500M
+post_max_size = 512M
+max_execution_time = 300
diff --git a/docker/production/web-server/Dockerfile b/docker/production/web-server/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..3b2320a246e0861b3bd7490db458096760d33b54
--- /dev/null
+++ b/docker/production/web-server/Dockerfile
@@ -0,0 +1,20 @@
+FROM docker.io/nginx:1.21-alpine
+
+VOLUME /var/www/html/media
+
+EXPOSE 80
+
+WORKDIR /var/www/html
+
+COPY docker/production/web-server/entrypoint.sh /entrypoint.sh
+
+RUN chmod +x /entrypoint.sh && \
+    apk add --no-cache curl
+
+HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost || exit 1
+
+COPY docker/production/web-server/nginx.conf /etc/nginx/nginx.conf
+
+COPY castopod/public /var/www/html
+
+CMD ["/entrypoint.sh"]
diff --git a/docker/production/web-server/entrypoint.sh b/docker/production/web-server/entrypoint.sh
new file mode 100644
index 0000000000000000000000000000000000000000..4bd93726ed68df48bd072c04226afb629209a804
--- /dev/null
+++ b/docker/production/web-server/entrypoint.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+if [ -z "${CP_HOST_BACK}" ]
+then
+	echo "CP_HOST_BACK is empty, using default"
+	CP_HOST_BACK="back"
+fi
+
+sed -i "s/CP_HOST_BACK/${CP_HOST_BACK}/" /etc/nginx/nginx.conf
+nginx -g "daemon off;"
diff --git a/docker/production/web-server/nginx.conf b/docker/production/web-server/nginx.conf
new file mode 100644
index 0000000000000000000000000000000000000000..8000c77753dfd2d7aff87bb4736258a9366a5ac9
--- /dev/null
+++ b/docker/production/web-server/nginx.conf
@@ -0,0 +1,76 @@
+worker_processes auto;
+
+error_log  /var/log/nginx/error.log warn;
+pid        /var/run/nginx.pid;
+
+events {
+    worker_connections  1024;
+}
+
+http {
+    include       /etc/nginx/mime.types;
+    default_type  application/octet-stream;
+
+    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
+                      '$status $body_bytes_sent "$http_referer" '
+                      '"$http_user_agent" "$http_x_forwarded_for"';
+
+    access_log  /var/log/nginx/access.log  main;
+
+    sendfile        on;
+
+    keepalive_timeout  65;
+
+    set_real_ip_from  10.0.0.0/8;
+    set_real_ip_from  172.16.0.0/12;
+    set_real_ip_from  192.168.0.0/16;
+    real_ip_header    X-Real-IP;
+
+    upstream php-handler {
+        server CP_HOST_BACK:9000;
+    }
+
+    server {
+        listen 80;
+
+        root /var/www/html;
+
+        index index.php index.html index.htm;
+
+        client_max_body_size 1G;
+        fastcgi_buffers 64 4K;
+
+        gzip on;
+        gzip_vary on;
+        gzip_comp_level 4;
+        gzip_min_length 256;
+        gzip_types application/atom+xml application/javascript audio/mpeg application/rss+xml image/bmp image/png image/jpeg image/webp image/svg+xml image/x-icon video/mp4 text/css text/plain text/html;
+
+	location ~ /.*\.(png|ico|txt|js|js\.map)$ {
+            try_files $uri =404;
+        }
+
+        location ~ /(assets|media)/.*$ {
+            try_files $uri =404;
+        }
+
+        location /.well-known/GDPR.yml {
+            try_files $uri =404;
+        }
+
+        location / {
+            fastcgi_param SCRIPT_FILENAME /opt/castopod/public/index.php;
+            include fastcgi_params;
+            fastcgi_index index.php;
+            fastcgi_pass php-handler;
+        }
+
+        location ~ \.php$ {
+            try_files $uri =404;
+            fastcgi_param SCRIPT_FILENAME /opt/castopod/public/$fastcgi_script_name;
+            include fastcgi_params;
+            fastcgi_index index.php;
+            fastcgi_pass php-handler;
+        }
+    }
+}