Construcción de un pipeline CI/CD tolerante a fallos para aplicaciones PHP con Docker y Kubernetes
Introducción: CI/CD para proyectos PHP en 2026
En 2026, CI/CD no es una práctica opcional, sino un requisito fundamental para cualquier desarrollo PHP serio. El despliegue manual, las migraciones olvidadas y el clásico "funcionaba en mi máquina" son síntomas de equipos que aún no han construido un pipeline de automatización sólido.
Para proyectos PHP, especialmente los basados en Laravel o Symfony, la entrega continua de código conlleva riesgos adicionales: artefactos de Composer, configuraciones de OPcache, migraciones de base de datos, colas de tareas. Sin una automatización clara, cada despliegue es una prueba de estrés manual para el equipo.
En este artículo construiremos un pipeline CI/CD completo y tolerante a fallos para una aplicación PHP usando Docker, Kubernetes, GitLab CI y GitHub Actions. Analizaremos cada etapa: desde el linting del código hasta Canary Releases y el rollback automático.
Visión general de herramientas: GitHub Actions vs GitLab CI para PHP/Docker
Elegir una plataforma CI/CD es una decisión estratégica. Veamos los dos líderes aplicados a PHP y Docker:
GitLab CI/CD — integrado en GitLab, se configura mediante
.gitlab-ci.yml. Soporta Container Registry integrado, Auto DevOps y un sistema avanzado de entornos. Ideal para infraestructura self-hosted y equipos corporativos. Integración nativa con Kubernetes a través del GitLab Agent.GitHub Actions — se configura mediante YAML en el directorio
.github/workflows/. Enorme ecosistema de actions listas para usar, plan gratuito para repositorios públicos. Ideal para proyectos open-source y equipos que ya utilizan GitHub.
Para PHP/Docker, ambas herramientas funcionan igual de bien. GitLab CI gana en despliegues self-hosted y registry integrado. GitHub Actions destaca por la velocidad de inicio y el ecosistema de componentes listos para usar.
En este artículo mostraremos ejemplos para ambos, con énfasis en GitLab CI como solución más completa para proyectos PHP enterprise.
Contenedorización de la aplicación PHP: Dockerfile óptimo
La base de cualquier pipeline CI/CD para PHP es un Dockerfile bien diseñado. Usaremos compilación multi-stage, Alpine Linux y OPcache para un rendimiento óptimo.
# Dockerfile
# ==========================================
# Stage 1: Composer dependencies
# ==========================================
FROM composer:2.7 AS composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--no-scripts
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
# ==========================================
# Stage 2: Assets build (si hay frontend)
# ==========================================
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# ==========================================
# Stage 3: Production PHP image
# ==========================================
FROM php:8.3-fpm-alpine AS production
# Dependencias del sistema
RUN apk add --no-cache \
nginx \
supervisor \
libpq \
libzip \
&& apk add --no-cache --virtual .build-deps \
libpq-dev \
libzip-dev \
autoconf \
gcc \
g++ \
make \
&& docker-php-ext-install \
pdo_pgsql \
opcache \
zip \
pcntl \
bcmath \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
# Configuración de OPcache para producción
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.interned_strings_buffer=16" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.revalidate_freq=0" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
# Copiamos dependencias de las etapas anteriores
COPY --from=composer /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
# Permisos de acceso
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Configuración de Nginx
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
Principios clave de este Dockerfile:
Compilación multi-stage — la imagen final no contiene Composer, Node.js ni dependencias de compilación
Alpine Linux — imagen base mínima (~5 MB frente a ~900 MB de Debian)
OPcache con validate_timestamps=0 — máximo rendimiento en producción
Capas en caché —
composer.jsonycomposer.lockse copian por separado antes que el resto del código
Etapa CI: linting, pruebas y compilación de la imagen
PHP_CodeSniffer y PHPStan
El análisis estático es la primera barrera del pipeline. Configure phpcs.xml y phpstan.neon en la raíz del proyecto:
# phpstan.neon
parameters:
level: 8
paths:
- app
- tests
excludePaths:
- vendor
checkMissingIterableValueType: false
Configuración de PHPUnit
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
</php>
</phpunit>
Pipeline completo en .gitlab-ci.yml
A continuación, una configuración de GitLab CI lista para producción para PHP/Laravel con despliegue en Kubernetes:
# .gitlab-ci.yml
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
KUBERNETES_NAMESPACE: production
stages:
- validate
- test
- build
- migrate
- deploy
- verify
# ==========================================
# Plantillas
# ==========================================
.php_template: &php_template
image: php:8.3-cli-alpine
before_script:
- apk add --no-cache git unzip libzip-dev
- docker-php-ext-install zip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
- composer install --no-interaction --prefer-dist --optimize-autoloader
# ==========================================
# Stage: validate
# ==========================================
phpcs:
<<: *php_template
stage: validate
script:
- vendor/bin/phpcs --standard=PSR12 app/ --report=checkstyle --report-file=phpcs-report.xml
artifacts:
reports:
codequality: phpcs-report.xml
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
phpstan:
<<: *php_template
stage: validate
script:
- vendor/bin/phpstan analyse --memory-limit=512M --error-format=gitlab > phpstan-report.json || true
artifacts:
reports:
codequality: phpstan-report.json
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# ==========================================
# Stage: test
# ==========================================
unit_tests:
<<: *php_template
stage: test
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
DB_HOST: postgres
DB_DATABASE: test_db
DB_USERNAME: test_user
DB_PASSWORD: test_password
before_script:
- apk add --no-cache git unzip libzip-dev libpq-dev
- docker-php-ext-install zip pdo_pgsql
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
- composer install --no-interaction --prefer-dist
- cp .env.testing .env
- php artisan key:generate
- php artisan migrate --force
script:
- vendor/bin/phpunit --coverage-text --coverage-cobertura=coverage.xml
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
when: always
coverage: '/^\s*Lines:\s*\d+\.?\d*%/'
# ==========================================
# Stage: build
# ==========================================
build_image:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build
--cache-from $IMAGE_LATEST
--build-arg BUILDKIT_INLINE_CACHE=1
--tag $IMAGE_TAG
--tag $IMAGE_LATEST
.
- docker push $IMAGE_TAG
- docker push $IMAGE_LATEST
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH =~ /^release\/.*/
# ==========================================
# Stage: migrate
# ==========================================
run_migrations:
stage: migrate
image:
name: bitnami/kubectl:latest
entrypoint: [""]
script:
- kubectl config use-context $KUBE_CONTEXT
- |
kubectl run migration-$CI_COMMIT_SHORT_SHA \
--image=$IMAGE_TAG \
--restart=Never \
--namespace=$KUBERNETES_NAMESPACE \
--env="APP_ENV=production" \
--command -- php artisan migrate --force
- kubectl wait --for=condition=complete \
job/migration-$CI_COMMIT_SHORT_SHA \
--timeout=300s \
--namespace=$KUBERNETES_NAMESPACE || \
(kubectl logs -l job-name=migration-$CI_COMMIT_SHORT_SHA --namespace=$KUBERNETES_NAMESPACE && exit 1)
- kubectl delete pod migration-$CI_COMMIT_SHORT_SHA --namespace=$KUBERNETES_NAMESPACE
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- build_image
# ==========================================
# Stage: deploy (Rolling Update)
# ==========================================
deploy_production:
stage: deploy
image:
name: bitnami/kubectl:latest
entrypoint: [""]
environment:
name: production
url: https://app.example.com
script:
- kubectl config use-context $KUBE_CONTEXT
- kubectl set image deployment/php-app
php-app=$IMAGE_TAG
--namespace=$KUBERNETES_NAMESPACE
- kubectl rollout status deployment/php-app
--namespace=$KUBERNETES_NAMESPACE
--timeout=300s
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- run_migrations
# ==========================================
# Stage: verify (smoke tests)
# ==========================================
smoke_tests:
stage: verify
image: curlimages/curl:latest
script:
- sleep 10
- curl -f -s -o /dev/null https://app.example.com/health || (echo "Health check failed" && exit 1)
- echo "Deployment verified successfully"
needs:
- deploy_production
rules:
- if: $CI_COMMIT_BRANCH == "main"
GitHub Actions: workflow equivalente
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2, phpcs, phpstan
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: PHP CodeSniffer
run: vendor/bin/phpcs --standard=PSR12 app/
- name: PHPStan
run: vendor/bin/phpstan analyse --memory-limit=512M
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, zip, redis
coverage: xdebug
- run: composer install --no-interaction --prefer-dist
- run: cp .env.testing .env && php artisan key:generate
- run: php artisan migrate --force
- run: vendor/bin/phpunit --coverage-clover coverage.xml
- uses: codecov/codecov-action@v4
with:
file: coverage.xml
build-and-push:
needs: [validate, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Configure kubectl
run: |
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig.yaml
export KUBECONFIG=kubeconfig.yaml
- name: Deploy to Kubernetes
run: |
export KUBECONFIG=kubeconfig.yaml
kubectl set image deployment/php-app \
php-app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} \
--namespace=production
kubectl rollout status deployment/php-app \
--namespace=production --timeout=300s
Estrategias de despliegue en Kubernetes
Rolling Update — estrategia estándar
Rolling Update es la estrategia más utilizada para aplicaciones PHP. Kubernetes reemplaza gradualmente los pods antiguos por los nuevos:
# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: php-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # +1 pod nuevo durante la actualización
maxUnavailable: 0 # 0 pods no disponibles durante la actualización
template:
metadata:
labels:
app: php-app
version: "{{ .Values.image.tag }}"
spec:
containers:
- name: php-app
image: registry.example.com/php-app:{{ .Values.image.tag }}
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 30
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
envFrom:
- secretRef:
name: php-app-secrets
- configMapRef:
name: php-app-config
Blue-Green Deployment
Blue-Green permite mantener dos entornos idénticos y cambiar el tráfico de forma instantánea. Para PHP esto es especialmente útil en migraciones de gran envergadura:
# Despliegue de la versión green
kubectl apply -f kubernetes/deployment-green.yaml
# Esperamos a que green esté listo
kubectl rollout status deployment/php-app-green --timeout=300s
# Cambiamos el Service a green
kubectl patch service php-app-service \
-p '{"spec":{"selector":{"version":"green"}}}'
# Verificamos y revertimos si es necesario
kubectl patch service php-app-service \
-p '{"spec":{"selector":{"version":"blue"}}}'
Canary Releases
Los Canary Releases permiten dirigir un pequeño porcentaje del tráfico (5-10%) a la nueva versión e incrementarlo de forma gradual:
# deployment-canary.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app-canary
namespace: production
spec:
replicas: 1 # 1 de cada 10 pods = 10% del tráfico
selector:
matchLabels:
app: php-app
track: canary
template:
metadata:
labels:
app: php-app
track: canary
spec:
containers:
- name: php-app
image: registry.example.com/php-app:new-version
Gestión de secretos: Kubernetes Secrets y HashiCorp Vault
Nunca almacene secretos en el repositorio Git. Existen dos enfoques principales:
Kubernetes Secrets (nivel básico)
# Creación de un secreto desde el archivo .env
kubectl create secret generic php-app-secrets \
--from-literal=APP_KEY=base64:tu_clave \
--from-literal=DB_PASSWORD=contraseña_secreta \
--from-literal=REDIS_PASSWORD=contraseña_redis \
--namespace=production
# O mediante manifiesto (los valores deben estar codificados en base64)
apiVersion: v1
kind: Secret
metadata:
name: php-app-secrets
namespace: production
type: Opaque
data:
APP_KEY: YmFzZTY0OmtleQ==
DB_PASSWORD: c2VjcmV0
HashiCorp Vault (nivel producción)
Para entornos de producción, use Vault con Vault Agent Injector:
# kubernetes/vault-annotations.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "php-app"
vault.hashicorp.com/agent-inject-secret-env: "secret/data/php-app/production"
vault.hashicorp.com/agent-inject-template-env: |
{{- with secret "secret/data/php-app/production" -}}
export APP_KEY={{ .Data.data.app_key }}
export DB_PASSWORD={{ .Data.data.db_password }}
{{- end }}
En GitLab CI, los secretos se pasan mediante variables de entorno con enmascaramiento: Settings → CI/CD → Variables → Masked. En GitHub Actions use Settings → Secrets and variables → Actions.
Migraciones automáticas de base de datos
Las migraciones son un momento crítico del despliegue. Existen dos patrones:
Init Container (recomendado para Kubernetes)
# Agregue initContainers en deployment.yaml
spec:
initContainers:
- name: run-migrations
image: registry.example.com/php-app:{{ .Values.image.tag }}
command: ["php", "artisan", "migrate", "--force"]
envFrom:
- secretRef:
name: php-app-secrets
- configMapRef:
name: php-app-config
El Init Container se ejecutará antes que los pods principales y finalizará. Solo tras su éxito, Kubernetes iniciará el contenedor principal. Esto garantiza que las migraciones se ejecuten antes del arranque de la aplicación.
Principios de migraciones seguras
Migraciones retrocompatibles: el nuevo código debe funcionar con el esquema antiguo de la BD (patrón expand-contract)
Nunca elimine columnas en la misma versión en que las quita del código
Use transacciones en las migraciones para garantizar atomicidad
Pruebe el rollback: cada migración debe tener un método
down()
Rollback del despliegue: estrategias y automatización
Rollback manual con kubectl
# Ver el historial de despliegues
kubectl rollout history deployment/php-app --namespace=production
# Revertir a la versión anterior
kubectl rollout undo deployment/php-app --namespace=production
# Revertir a una revisión específica
kubectl rollout undo deployment/php-app \
--to-revision=3 \
--namespace=production
# Verificar el estado tras el rollback
kubectl rollout status deployment/php-app --namespace=production
Rollback automático en GitLab CI
# Agregue la etapa de rollback automático en .gitlab-ci.yml
auto_rollback:
stage: verify
image:
name: bitnami/kubectl:latest
entrypoint: [""]
script:
- kubectl config use-context $KUBE_CONTEXT
- |
# Verificamos el health endpoint 3 veces con intervalo de 10 segundos
for i in 1 2 3; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed (attempt $i), HTTP $STATUS"
if [ "$i" -eq 3 ]; then
echo "Rolling back deployment..."
kubectl rollout undo deployment/php-app --namespace=$KUBERNETES_NAMESPACE
exit 1
fi
sleep 10
else
echo "Health check passed"
exit 0
fi
done
needs:
- deploy_production
when: on_success
Monitoreo del pipeline y notificaciones
Un pipeline tolerante a fallos sin monitoreo es una ilusión de fiabilidad. Integre las siguientes herramientas:
Notificaciones en Slack/Telegram desde GitLab CI
# Agregue al final de .gitlab-ci.yml
notify_success:
stage: .post
image: curlimages/curl:latest
script:
- |
curl -X POST $SLACK_WEBHOOK_URL \
-H 'Content-type: application/json' \
--data '{"text":"✅ Despliegue de *'$CI_PROJECT_NAME'* exitoso. Commit: '$CI_COMMIT_SHORT_SHA' ('$CI_COMMIT_AUTHOR')"}'
when: on_success
rules:
- if: $CI_COMMIT_BRANCH == "main"
notify_failure:
stage: .post
image: curlimages/curl:latest
script:
- |
curl -X POST $SLACK_WEBHOOK_URL \
-H 'Content-type: application/json' \
--data '{"text":"🚨 ¡Despliegue de *'$CI_PROJECT_NAME'* FALLIDO! Pipeline: '$CI_PIPELINE_URL'"}'
when: on_failure
rules:
- if: $CI_COMMIT_BRANCH == "main"
Métricas del pipeline: qué monitorear
Deployment Frequency — con qué frecuencia despliega el equipo (métrica DORA)
Lead Time for Changes — tiempo desde el commit hasta producción
Change Failure Rate — porcentaje de despliegues que requirieron rollback
Mean Time to Recovery (MTTR) — tiempo promedio de recuperación tras un fallo
Para el monitoreo de Kubernetes use la combinación Prometheus + Grafana. Para el rastreo de despliegues, ArgoCD o el dashboard de GitLab Environments integrado.
Endpoint /health para PHP/Laravel
ReadinessProbe y LivenessProbe en Kubernetes requieren un endpoint de health funcional. Agréguelo en Laravel:
<?php
// routes/web.php
Route::get('/health', function () {
try {
// Verificamos la conexión con la BD
DB::select('SELECT 1');
// Verificamos Redis
Redis::ping();
return response()->json([
'status' => 'ok',
'timestamp' => now()->toISOString(),
'version' => config('app.version'),
], 200);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 503);
}
})->middleware('throttle:60,1');
Checklist: señales de un pipeline CI/CD listo para producción
Todos los secretos están almacenados en Vault o en variables CI/CD (no en el código)
El pipeline no despliega si los tests fallan
Las imágenes Docker tienen tags específicos (no solo
latest)Las migraciones se ejecutan mediante Init Container, no manualmente
ReadinessProbe configurado — Kubernetes no envía tráfico a pods no listos
El rollback tarda no más de 2 minutos
El equipo recibe notificaciones sobre el estado de cada despliegue
Cobertura de tests mínima del 70%, el informe se publica en MR/PR
La compilación tarda no más de 10 minutos (de lo contrario los desarrolladores ignoran el pipeline)
Conclusión
Construir un pipeline CI/CD tolerante a fallos para PHP con Docker y Kubernetes es una inversión que se amortiza con el primer despliegue fallido que evita. Comience con un pipeline básico: linting → pruebas → compilación → despliegue. Luego itere: agregue gestión de secretos con Vault, configure el rollback automático e implemente Canary Releases.
Principio clave: cada paso del pipeline debe estar automatizado, ser idempotente y observable. Si algo no puede revertirse automáticamente, es un riesgo que debe eliminarse antes del próximo despliegue.
Invierta tiempo en la calidad del pipeline hoy, y su equipo podrá desplegar en viernes sin miedo.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →