Tech stack: Laravel (APIs & Scheduler), PHP 8.4, MySQL 8.4, Nginx — everything in Docker containers.
Environments: Local development → Dev → Prod
Source control: Private GitHub repo (my-laravel-project)
Security stance: No secrets in Git ever; never bake secrets into images; pin prod images by digest; use Docker secrets in prod.
Table of Contents
- Phase 0 — Prepare the Repository
- Phase 1 — Local Development with Docker
- Phase 1.5 — Git Beginner Workflow
- Phase 2 — Push Images to GHCR Manually
- Phase 3 — Deploy on Dev VM (RHEL)
- Phase 4 — Plan & Deploy on Prod VM (RHEL, Pinned Digest)
- Phase 5 — GitHub Actions (PR verify, build & push, deploy)
- RHEL / SELinux / Firewall Notes
- Security Checklist
- Troubleshooting
- Appendix — File & Variable Cheat-Sheet
Phase 0 — Prepare the Repository (one-time)
Your repo root is the Laravel project directory: my-laravel-project/.
Recommended structure
my-laravel-project/
├─ app/ … (Laravel code)
├─ bootstrap/
├─ config/
├─ database/
├─ public/
├─ resources/
├─ routes/
├─ storage/
├─ vendor/ # ignored by Git
├─ .env # optional for local non-Docker runs; never committed
├─ .env.example # commit a safe template
├─ .gitignore
├─ .dockerignore
├─ Dockerfile # prod (php-fpm)
├─ Dockerfile.dev # dev
├─ docker-compose.dev.yml
├─ docker-compose.dev.server.yml
├─ docker-compose.prod.yml
└─ deploy/
├─ nginx.conf
├─ entrypoint.sh
├─ php.ini # optional PHP overrides
├─ dev.app.env.example # commit a template (no secrets)
├─ prod.app.env.example # commit a template (no secrets)
└─ README-secrets.md # explain where to store real secrets
.gitignore
/vendor /node_modules /.idea /.vscode .env deploy/*.env deploy/*.secret
.dockerignore (must be at repo root)
.git vendor node_modules storage/*.key .env .env.* deploy/*.env deploy/*.secret docker-compose*.yml
Why: keeps secrets and heavy folders out of the build context so they never get included in an image.
Phase 1 — Local Development with Docker
1) Install prerequisites
- Windows/macOS: install Docker Desktop.
- Linux: install Docker Engine + Docker Compose plugin.
- RHEL notes are at the end (Docker CE install, SELinux, firewalld).
2) Dockerfile (prod, PHP 8.4 FPM)
Save as Dockerfile (repo root).
# syntax=docker/dockerfile:1.7 FROM php:8.4-fpm-alpine
RUN apk add --no-cache icu-dev libzip-dev oniguruma-dev bash curl tzdata
&& docker-php-ext-install -j"$(nproc)" pdo pdo_mysql intl zip opcache
&& { echo "opcache.enable=1"; echo "opcache.enable_cli=0"; echo "opcache.validate_timestamps=0"; }
> /usr/local/etc/php/conf.d/opcache.ini
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --no-interaction --no-ansi --no-progress
COPY . .
RUN composer dump-autoload -o
&& mkdir -p storage bootstrap/cache
&& chown -R www-data:www-data storage bootstrap/cache
Optional PHP overrides
COPY deploy/php.ini /usr/local/etc/php/conf.d/99-custom.ini
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 9000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["php-fpm","-F"]
3) Dockerfile.dev (development image)
Save as Dockerfile.dev.
# syntax=docker/dockerfile:1.7 FROM php:8.4-fpm-alpine ARG INSTALL_XDEBUG=false
RUN apk add --no-cache icu-dev libzip-dev oniguruma-dev bash curl tzdata git
&& docker-php-ext-install -j"$(nproc)" pdo pdo_mysql intl zip
RUN if [ "$INSTALL_XDEBUG" = "true" ]; then
apk add --no-cache $PHPIZE_DEPS && pecl install xdebug && docker-php-ext-enable xdebug &&
{ echo "xdebug.mode=develop,debug"; echo "xdebug.client_host=host.docker.internal"; }
> /usr/local/etc/php/conf.d/xdebug.ini ;
fi
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
CMD ["php-fpm","-F"]
4) Nginx reverse proxy
Save as deploy/nginx.conf.
server { listen 8080; server_name _; root /var/www/html/public; index index.php;
location / {
try_files $uri /index.php?$query_string;
}
location ~ .php$ {
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_read_timeout 180;
}
location ^~ /storage/ {
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
client_max_body_size 20m;
}
5) entrypoint.sh (what it does)
Save as deploy/entrypoint.sh (make it executable).
#!/usr/bin/env bash set -euo pipefail
Load docker secrets into env if present (files under /run/secrets/*)
load_secret(){ [ -f "/run/secrets/$1" ] && [ -z "${!1:-}" ] && export "$1"="$(cat "/run/secrets/$1")"; }
for s in APP_KEY DB_PASSWORD DB_USERNAME DB_HOST DB_DATABASE; do load_secret "$s" || true; done
cd /var/www/html
Permissions Laravel needs
mkdir -p storage bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache || true
chmod -R ug+rw storage bootstrap/cache || true
Enforce APP_KEY in prod; auto-generate for dev if missing
if [ -z "${APP_KEY:-}" ]; then
if [ "${APP_ENV:-production}" = "production" ]; then
echo "ERROR: APP_KEY missing in production." >&2
exit 1
else
export APP_KEY="$(php artisan key:generate --show || true)"
fi
fi
Warm caches (prod)
if [ "${APP_ENV:-production}" = "production" ]; then
php artisan config:cache || true
php artisan route:cache || true
php artisan view:cache || true
fi
Auto-migrate if enabled
[ "${RUN_MIGRATIONS:-false}" = "true" ] && php artisan migrate --force || true
exec "$@"
6) docker-compose.dev.yml (local dev)
services: php: build: context: . dockerfile: Dockerfile.dev args: { INSTALL_XDEBUG: "false" } # flip to true for debugging env_file: [ ./deploy/dev.app.env ] # created locally; .gitignored volumes: - ./:/var/www/html - composer-cache:/var/www/html/vendor depends_on: [ db ] networks: [ appnet ]
nginx:
image: nginx:1.27-alpine
ports: [ "8080:8080" ]
depends_on: [ php ]
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./public:/var/www/html/public:ro
- ./storage/app/public:/var/www/html/public/storage
networks: [ appnet ]
db:
image: mysql:8.4
command: ["mysqld","--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci"]
environment:
MYSQL_DATABASE: app
MYSQL_USER: app
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: root
ports: [ "3306:3306" ] # dev only
volumes: [ dbdata:/var/lib/mysql ]
networks: [ appnet ]
volumes:
dbdata:
composer-cache:
networks:
appnet:
Create deploy/dev.app.env (locally, do NOT commit):
APP_ENV=local APP_DEBUG=true APP_URL=http://localhost:8080
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=app
DB_USERNAME=app
DB_PASSWORD=secret
QUEUE_CONNECTION=database
Start local dev:
docker compose -f docker-compose.dev.yml up -d --build docker compose -f docker-compose.dev.yml exec php php artisan migrate docker compose -f docker-compose.dev.yml logs -f php
Phase 1.5 — Git Beginner Workflow
# first clone git clone git@github.com:<ORG>/<REPO>.git cd my-laravel-project
create feature from development
git checkout development
git pull
git checkout -b feature/add-users-endpoint
work locally, run tests, then commit
git add .
git commit -m "feat(users): add index endpoint + tests"
push and open PR
git push -u origin feature/add-users-endpoint
Branching convention: development → Dev VM, main → Prod VM.
Phase 2 — Push Images to GHCR Manually (no Actions yet)
1) Create a GitHub Personal Access Token (PAT)
- Fine-grained PAT scoped to your repo/org: Packages: Read & Write.
- If the org enforces SSO, authorize the PAT to that org.
2) Docker login to GHCR (from your laptop)
export GH_USER="<github_username_or_org_service_account>" export GH_PAT="<pat_with_write:packages>" echo "$GH_PAT" | docker login ghcr.io -u "$GH_USER" --password-stdin
3) Build & push the Dev image
export OWNER="<github-org-or-username>>" export REPO="<repo-name>" export IMG="ghcr.io/${OWNER,,}/${REPO,,}-php" export SHA=$(git rev-parse --short HEAD)
docker build -f Dockerfile.dev -t "$IMG:dev" -t "$IMG:dev-$SHA" .
docker push "$IMG:dev" "$IMG:dev-$SHA"
4) Build & push the Prod image and capture digest
docker build -f Dockerfile -t "$IMG:prod" -t "$IMG:prod-$SHA" . docker push "$IMG:prod" "$IMG:prod-$SHA"
print immutable digest to pin in prod compose
docker pull "$IMG:prod"
docker inspect --format='{{index .RepoDigests 0}}' "$IMG:prod"
Example: ghcr.io/owner/repo-php@sha256:abcdef...
On Apple Silicon (ARM) with AMD64 servers? Publish multi-arch:
docker buildx create --name multi --use >/dev/null 2>&1 || true
docker buildx build --platform linux/amd64,linux/arm64
-f Dockerfile.dev -t "$IMG:dev" -t "$IMG:dev-$SHA" --push .
docker buildx build --platform linux/amd64,linux/arm64
-f Dockerfile -t "$IMG:prod" -t "$IMG:prod-$SHA" --push .
Phase 3 — Deploy on Dev VM (RHEL)
0) Install Docker CE on RHEL (summary)
sudo dnf -y install dnf-plugins-core sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo systemctl enable --now docker sudo usermod -aG docker $USER newgrp docker
SELinux: prefer named volumes. For bind mounts (e.g. nginx.conf), add :Z to relabel.
1) Create project folder on Dev VM
sudo mkdir -p /opt/myapi/deploy cd /opt/myapi
2) docker-compose.dev.server.yml (image-based dev)
Save on the Dev VM: /opt/myapi/docker-compose.dev.server.yml
services: php: image: ghcr.io/<owner>/<repo>-php:dev pull_policy: always env_file: [ ./deploy/dev.app.env ] environment: { RUN_MIGRATIONS: "true", APP_ENV: local, APP_DEBUG: "true" } depends_on: [ db ] networks: [ appnet ] volumes: - storage:/var/www/html/storage - cache:/var/www/html/bootstrap/cache
nginx:
image: nginx:1.27-alpine
depends_on: [ php ]
ports: [ "8080:8080" ]
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z
- storage:/var/www/html/public/storage:ro
networks: [ appnet ]
restart: unless-stopped
db:
image: mysql:8.4
command: ["mysqld","--character-set-server=utf8mb4","--collation-server=utf8mb4_unicode_ci"]
env_file: [ ./deploy/dev.db.env ]
volumes: [ dbdata:/var/lib/mysql ]
networks: [ appnet ]
restart: unless-stopped
volumes:
dbdata:
storage:
cache:
networks:
appnet:
3) Env files on Dev VM (do NOT commit)
Save /opt/myapi/deploy/dev.app.env:
APP_NAME=MyAPI APP_ENV=local APP_DEBUG=true APP_URL=http://<dev-ip>:8080
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=app
DB_USERNAME=app
DB_PASSWORD=secret
QUEUE_CONNECTION=database
Save /opt/myapi/deploy/dev.db.env:
MYSQL_DATABASE=app MYSQL_USER=app MYSQL_PASSWORD=secret MYSQL_ROOT_PASSWORD=root
4) Login to GHCR on the Dev VM & deploy
export GH_USER="<github_user_or_service_acct>" export GH_PAT="<pat_with_read:packages>" echo "$GH_PAT" | docker login ghcr.io -u "$GH_USER" --password-stdin
docker compose -f docker-compose.dev.server.yml pull
docker compose -f docker-compose.dev.server.yml up -d
Phase 4 — Plan & Deploy on Prod VM (RHEL, Pinned Digest)
1) docker-compose.prod.yml (use pinned digest + secrets)
Save on the Prod VM: /opt/myapi/docker-compose.prod.yml
services: php: image: ghcr.io/<owner>/<repo>-php:prod@sha256:<digest> pull_policy: always env_file: [ ./deploy/app.env ] # server-local, non-secret secrets: [ APP_KEY, DB_PASSWORD ] # real secrets as files environment: { APP_ENV: production, APP_DEBUG: "false", RUN_MIGRATIONS: "false" } volumes: - storage:/var/www/html/storage - cache:/var/www/html/bootstrap/cache networks: [ private ] restart: unless-stopped security_opt: [ "no-new-privileges:true" ] tmpfs: [ /tmp ]
nginx:
image: nginx:1.27-alpine
depends_on: [ php ]
ports: [ "80:8080" ] # or terminate TLS here on 443
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z
- storage:/var/www/html/public/storage:ro
networks: [ public, private ]
restart: unless-stopped
read_only: true
tmpfs: [ /var/run, /var/cache/nginx ]
cap_drop: [ "ALL" ]
security_opt: [ "no-new-privileges:true" ]
db:
image: mysql:8.4
env_file: [ ./deploy/db.env ]
volumes: [ dbdata:/var/lib/mysql ]
networks: [ private ]
restart: unless-stopped
secrets:
APP_KEY: { file: ./deploy/APP_KEY.secret } # base64:... from artisan key:generate --show
DB_PASSWORD: { file: ./deploy/DB_PASSWORD.secret }
volumes:
dbdata:
storage:
cache:
networks:
public: { driver: bridge }
private: { driver: bridge }
2) Server-local files (never commit)
Save /opt/myapi/deploy/app.env (non-secret runtime settings):
APP_NAME=MyAPI APP_ENV=production APP_DEBUG=false APP_URL=https://api.example.com
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=app
DB_USERNAME=app
DB_PASSWORD via secret
QUEUE_CONNECTION=database
LOG_LEVEL=info
Save /opt/myapi/deploy/db.env (DB bootstrap):
MYSQL_DATABASE=app MYSQL_USER=app MYSQL_PASSWORD=<strong-password> MYSQL_ROOT_PASSWORD=<very-strong-root>
Save secrets (files):
deploy/APP_KEY.secret→ output ofphp artisan key:generate --showdeploy/DB_PASSWORD.secret→ same value used as DB user password
3) Login & deploy
echo "<PAT_with_read:packages>" | docker login ghcr.io -u "<github_user>" --password-stdin docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d
Migrations (manual, explicit)
docker compose -f docker-compose.prod.yml exec php php artisan migrate --force
Next releases: push a new :prod, capture the new digest, update compose, then pull and up -d.
Phase 5 — GitHub Actions (PR verify, build & push, deploy)
Start manually (Phases 2–4). When stable, add automation:
GitHub Actions secrets to add
- DEV_SSH_HOST, DEV_SSH_USER, DEV_SSH_KEY (for Dev VM deploy via SSH).
- PROD_SSH_HOST, PROD_SSH_USER, PROD_SSH_KEY (optional; use environment protection).
- GHCR_PAT_READ (optional). Most steps can use
GITHUB_TOKENon runner; remote servers still need their own login.
A) PR verification (build & test; no push)
Save as .github/workflows/ci-pr.yml
name: CI — PR Verify on: pull_request: { branches: [ development, main ] }
jobs:
verify:
runs-on: ubuntu-latest
permissions: { contents: read, packages: read }
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
tools: composer:v2
- run: composer install --no-interaction --prefer-dist --no-progress
- run: php artisan test --no-interaction || vendor/bin/phpunit
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.dev
push: false
tags: pr-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: pr-test:${{ github.sha }}
vuln-type: 'os,library'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
exit-code: '1'
B) Build & Push Dev on development
Save as .github/workflows/dev-build.yml
name: Build & Push — Dev on: push: { branches: [ development ] }
jobs:
build-dev:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Build & push dev (multi-arch)
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}-php"
docker buildx build --platform linux/amd64,linux/arm64
-f Dockerfile.dev -t "$IMAGE:dev" -t "$IMAGE:dev-${GITHUB_SHA}" --push .
C) Auto-deploy Dev (SSH to Dev VM)
Save as .github/workflows/dev-deploy.yml
name: Deploy — Dev on: workflow_run: workflows: [ "Build & Push — Dev" ] types: [ completed ]
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Write SSH key
run: |
echo "${{ secrets.DEV_SSH_KEY }}" > id_rsa
chmod 600 id_rsa
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -t ed25519,rsa ${{ secrets.DEV_SSH_HOST }} >> ~/.ssh/known_hosts
- name: SSH deploy
env:
GHCR_USER: ${{ github.actor }}
GHCR_PAT: ${{ secrets.GITHUB_TOKEN }}
run: |
ssh -i id_rsa ${{ secrets.DEV_SSH_USER }}@${{ secrets.DEV_SSH_HOST }} <<'EOF'
set -e
cd /opt/myapi
echo "$GHCR_PAT" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
docker compose -f docker-compose.dev.server.yml pull
docker compose -f docker-compose.dev.server.yml up -d
EOF
D) Build & Push Prod on main (emit digest)
Save as .github/workflows/prod-build.yml
name: Build & Push — Prod on: push: branches: [ main ] tags: [ 'v*.*.*' ]
jobs:
build-prod:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Build & push prod (multi-arch)
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}-php"
docker buildx build --platform linux/amd64,linux/arm64
-f Dockerfile -t "$IMAGE:prod" -t "$IMAGE:prod-${GITHUB_SHA}" --push .
- name: Output digest (for compose pinning)
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}-php:prod"
docker buildx imagetools inspect "$IMAGE"
E) Deploy Prod (approval-gated)
Save as .github/workflows/prod-deploy.yml
name: Deploy — Prod on: workflow_dispatch: inputs: digest: description: "Image digest (ghcr.io/owner/repo-php@sha256:...)" required: true
env:
TARGET_DIR: /opt/myapi
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # require reviewers in Environments
steps:
- name: Write SSH key
run: |
echo "${{ secrets.PROD_SSH_KEY }}" > id_rsa
chmod 600 id_rsa
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -t ed25519,rsa ${{ secrets.PROD_SSH_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via SSH
env:
DIGEST: ${{ inputs.digest }}
GHCR_USER: ${{ github.actor }}
GHCR_PAT: ${{ secrets.GITHUB_TOKEN }}
run: |
ssh -i id_rsa ${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }} <<'EOF'
set -e
cd $TARGET_DIR
# Update compose to use the new $DIGEST (template your compose to read an env var, or edit file then pull)
echo "$GHCR_PAT" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
EOF
Tip: Template your prod compose (e.g., set image: ${PHP_IMAGE}) and pass PHP_IMAGE as the digest string to avoid editing files on the server.
RHEL / SELinux / Firewall Notes
- SELinux: Prefer named volumes. For bind mounts, add
:Zto relabel paths for containers. - firewalld:
sudo firewall-cmd --add-service=http --add-service=https --permanent && sudo firewall-cmd --reload - Enable Docker:
sudo systemctl enable --now docker - Podman vs Docker: This guide uses Docker CE. Podman works but compose flags differ.
Security Checklist (must-do)
- Never commit secrets. Keep
deploy/*.envanddeploy/*.secretout of Git. .dockerignoreexcludes.env, so secrets never enter images.- Use Docker secrets in prod (mounted under
/run/secrets, loaded byentrypoint.sh). - Pin prod images by digest (
@sha256:…). Avoid:latestin prod. - Expose only Nginx to the host. Keep PHP-FPM and MySQL on a private network; do not publish port 3306 in prod.
- Harden containers:
no-new-privileges,read_only(Nginx),tmpfsfor tmp paths. - Rotate DB credentials periodically; rotate PATs as needed.
- Back up volumes:
dbdata(andstorageif you store uploads).
Troubleshooting
- 502 from Nginx → Check PHP logs:
docker compose logs -f php nginx; ensurefastcgi_pass php:9000and service names match. - Migrations fail on first boot → Set
RUN_MIGRATIONS=truefor Dev; in Prod, run migrations manually. - Server still runs old image → Run
docker compose pullbeforeup -d; verify tag/digest. - GHCR denied → Ensure PAT scopes (
read:packageson servers,write:packageswhen pushing) and SSO org authorization. - Apple Silicon vs AMD64 → Use
buildxmulti-arch commands as shown.
Appendix — File & Variable Cheat-Sheet
Files you create
Dockerfile,Dockerfile.dev,deploy/nginx.conf,deploy/entrypoint.sh,docker-compose.dev.ymldocker-compose.dev.server.yml(Dev VM),docker-compose.prod.yml(Prod VM)deploy/dev.app.env(local + Dev VM),deploy/dev.db.env(Dev VM)deploy/app.env,deploy/db.env,deploy/APP_KEY.secret,deploy/DB_PASSWORD.secret(Prod VM)
Variable glossary
OWNER/REPO→ your GitHub org/user and repo name (lowercase in GHCR paths).IMG→ghcr.io/<owner>/<repo>-php.GH_USER→ GitHub username or org service account used for registry login.GH_PAT→ Personal Access Token;write:packagesto push,read:packagesto pull.SHA→git rev-parse --short HEAD(handy for image tagging).