Manual Steps

Manual Docker only — create code yourself, then build & run

This is the only way documented for this project.

You will:

  1. Create folders and every source file yourself (PHP or Node)
  2. Paste code from the linked files (or from the blocks below)
  3. Write a Dockerfile piece by piece
  4. docker pull images
  5. docker network create
  6. docker volume create (named volumes only — no host folder bind mounts)
  7. docker build (code goes into the image with COPY)
  8. docker run MySQL + app

You create .env + docker-compose.yml before running. Use manual docker run or docker compose up. There is no “mount ./application from the host”. Code is copied into the image at build time. Named volumes only hold MySQL data and uploaded files.


How code gets into the container

You create files on your PC
        ↓
Dockerfile: COPY application/ ...
        ↓
docker build   →  files baked into YOUR image
        ↓
docker run     →  container runs that image

Uploads / MySQL data use named volumes (created with docker volume create), not Windows folder mounts.


One-time: Docker in WSL

sudo service docker start
docker --version

If Docker is not installed yet, follow the install section in GUIDE.md (WSL + Docker Engine only).


Pick one stack

StackWorking folder you will createBrowserMySQL port
PHP~/docker-task-php (or any empty folder)http://localhost:80803306
Node~/assignment-docker-nodehttp://localhost:30003307

Reference copies of the finished code already live under:

Open those links in your editor if you prefer copy-from-file instead of paste-from-guide.


Manual Docker only — create code yourself, then build & run

This is the only way documented for this project.

You will:

  1. Create folders and every source file yourself (PHP or Node)
  2. Paste code from the linked files (or from the blocks below)
  3. Write a Dockerfile piece by piece
  4. docker pull images
  5. docker network create
  6. docker volume create (named volumes only — no host folder bind mounts)
  7. docker build (code goes into the image with COPY)
  8. docker run MySQL + app

You create .env + docker-compose.yml before running. Use manual docker run or docker compose up. There is no “mount ./application from the host”. Code is copied into the image at build time. Named volumes only hold MySQL data and uploaded files.


How code gets into the container

You create files on your PC
        ↓
Dockerfile: COPY application/ ...
        ↓
docker build   →  files baked into YOUR image
        ↓
docker run     →  container runs that image

Uploads / MySQL data use named volumes (created with docker volume create), not Windows folder mounts.


One-time: Docker in WSL

sudo service docker start
docker --version

If Docker is not installed yet, follow the install section in GUIDE.md (WSL + Docker Engine only).


Pick one stack

StackWorking folder you will createBrowserMySQL port
PHP~/docker-task-php (or any empty folder)http://localhost:80803306
Node~/assignment-docker-nodehttp://localhost:30003307

Reference copies of the finished code already live under:

Open those links in your editor if you prefer copy-from-file instead of paste-from-guide.


Other stack: Node.js manual → · back to overview

PATH A — PHP (create everything manually)

A1. Create project folders

mkdir -p ~/docker-task-php/application
cd ~/docker-task-php

Final layout you will build:

docker-task-php/
├── .env                       ← you create
├── .env.example               ← optional
├── .dockerignore              ← you create
├── docker-compose.yml         ← you create
├── Dockerfile                 ← you create
└── application/
    ├── db.php                 ← you create
    ├── auth.php               ← you create
    ├── style.css              ← you create (central styles)
    ├── favorites.js           ← you create
    ├── index.php              ← you create
    ├── login.php              ← you create
    ├── register.php           ← you create
    ├── logout.php             ← you create
    ├── edit.php               ← you create
    ├── delete.php             ← you create
    └── favorites.php          ← you create

UI notes (PHP path):

  • Central style.css — light canvas #f5f5f5, ink text, white cards, ink-pill primary buttons, soft gradient orbs
  • Inter font from Google Fonts
  • Auth — register / login / logout; dashboard shows only your own images
  • Sort images by title / upload date
  • Search your uploaded images
  • Rename image names inline (edit page)
  • Favorites for images (browser localStorage; no extra DB table)

A2. Create PHP files one by one

Note: the embedded PHP code blocks on this page are from the previous app version. The current (auth + own-image dashboard) code is in MANUAL_STEPS.md and in php-app/application/ — copy from those instead. The file list below matches the current app: db.php, auth.php, style.css, favorites.js, index.php, login.php, register.php, logout.php, edit.php, delete.php, favorites.php.

For each file: nano application/FILENAME → paste → save.

File 1 — application/db.php (DB connection)

Link (reference): php-app/application/db.php

nano application/db.php
<?php
// Simple database connection using PDO
// Values come from Docker environment variables
// Database and tables must already exist (see SETUP_DATABASE.md)

$host = getenv('DB_HOST') ?: 'db';
$name = getenv('DB_NAME') ?: 'userdb';
$user = getenv('DB_USER') ?: 'appuser';
$pass = getenv('DB_PASS') ?: 'apppass';

try {
    $pdo = new PDO(
        "mysql:host=$host;dbname=$name;charset=utf8mb4",
        $user,
        $pass,
        [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
    );
} catch (PDOException $e) {
    die("Database connection failed: " . $e->getMessage()
        . " — Create the database and tables first (see SETUP_DATABASE.md).");
}

File 2 — application/style.css (design.md light theme)

Link (reference): php-app/application/style.css

nano application/style.css
/* =========================================================
   User Management — shared styles
   Palette: cream bg · black text · white cards · pastel blue primary · red delete
   ========================================================= */

:root {
  --bg: #f7f3eb;
  --bg-soft: #efe9dc;
  --surface: #ffffff;
  --text: #1a1a1a;
  --text-muted: #5c5c5c;
  --border: #e4ddd0;
  --primary: #7eb8da;
  --primary-hover: #6aa8cc;
  --primary-text: #0f2a3a;
  --danger: #d64545;
  --danger-hover: #c03939;
  --danger-soft: #fdecec;
  --shadow: 0 8px 24px rgba(26, 26, 26, 0.06);
  --radius: 12px;
  --radius-sm: 8px;
  --max: 960px;
  --font: "Segoe UI", system-ui, -apple-system, sans-serif;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--font);
  color: var(--text);
  background: var(--bg);
  line-height: 1.5;
}

a {
  color: var(--primary-text);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

img {
  max-width: 100%;
  height: auto;
  display: block;
}

/* ---------- Layout ---------- */

.page {
  width: min(100% - 2rem, var(--max));
  margin: 0 auto;
  padding: 1.5rem 0 3rem;
}

.site-header {
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  box-shadow: var(--shadow);
  position: sticky;
  top: 0;
  z-index: 20;
}

.site-header__inner {
  width: min(100% - 2rem, var(--max));
  margin: 0 auto;
  padding: 0.85rem 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  color: var(--text);
  font-weight: 700;
  font-size: 1.05rem;
  text-decoration: none;
}

.brand:hover {
  text-decoration: none;
  color: var(--text);
}

.brand i,
.brand svg {
  color: var(--primary);
}

.nav {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
}

.nav a {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.45rem 0.75rem;
  border-radius: 999px;
  color: var(--text-muted);
  font-size: 0.9rem;
  font-weight: 500;
  text-decoration: none;
  transition: background 0.15s, color 0.15s;
}

.nav a:hover,
.nav a.is-active {
  background: var(--bg-soft);
  color: var(--text);
  text-decoration: none;
}

/* ---------- Typography ---------- */

h1 {
  margin: 0 0 0.35rem;
  font-size: clamp(1.4rem, 3vw, 1.85rem);
  font-weight: 700;
  letter-spacing: -0.02em;
}

.lead {
  margin: 0 0 1.25rem;
  color: var(--text-muted);
  font-size: 0.95rem;
}

.page-title {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  margin-bottom: 1.25rem;
}

.page-title .lead {
  margin-bottom: 0;
}

/* ---------- Cards / panels ---------- */

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 1.25rem;
}

.card + .card {
  margin-top: 1rem;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
  margin-bottom: 1rem;
}

.toolbar-spacer {
  flex: 1 1 auto;
}

/* ---------- Forms ---------- */

.form-stack {
  display: grid;
  gap: 1rem;
  max-width: 480px;
}

label {
  display: block;
  font-size: 0.875rem;
  font-weight: 600;
  margin-bottom: 0.35rem;
  color: var(--text);
}

.field-hint {
  display: block;
  margin-top: 0.3rem;
  font-size: 0.8rem;
  color: var(--text-muted);
  font-weight: 400;
}

input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="file"],
select {
  width: 100%;
  padding: 0.65rem 0.8rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text);
  font: inherit;
  transition: border-color 0.15s, box-shadow 0.15s;
}

input:focus,
select:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px rgba(126, 184, 218, 0.35);
}

.search-field {
  position: relative;
  flex: 1 1 220px;
  min-width: 180px;
  max-width: 360px;
}

.search-field i,
.search-field svg {
  position: absolute;
  left: 0.7rem;
  top: 50%;
  transform: translateY(-50%);
  width: 1rem;
  height: 1rem;
  color: var(--text-muted);
  pointer-events: none;
}

.search-field input {
  padding-left: 2.2rem;
}

.sort-field {
  flex: 0 1 180px;
  min-width: 140px;
}

/* ---------- Buttons ---------- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  padding: 0.6rem 1rem;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  font: inherit;
  font-weight: 600;
  font-size: 0.9rem;
  cursor: pointer;
  text-decoration: none;
  transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
  white-space: nowrap;
}

.btn:hover {
  text-decoration: none;
}

.btn:active {
  transform: translateY(1px);
}

.btn i,
.btn svg {
  width: 1rem;
  height: 1rem;
  flex-shrink: 0;
}

.btn-primary {
  background: var(--primary);
  color: var(--primary-text);
  border-color: transparent;
}

.btn-primary:hover {
  background: var(--primary-hover);
  color: var(--primary-text);
}

.btn-ghost {
  background: transparent;
  color: var(--text);
  border-color: var(--border);
}

.btn-ghost:hover {
  background: var(--bg-soft);
  color: var(--text);
}

.btn-danger {
  background: var(--danger);
  color: #fff;
  border-color: transparent;
}

.btn-danger:hover {
  background: var(--danger-hover);
  color: #fff;
}

.btn-danger-soft {
  background: var(--danger-soft);
  color: var(--danger);
  border-color: transparent;
}

.btn-danger-soft:hover {
  background: var(--danger);
  color: #fff;
}

.btn-icon {
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  border-radius: var(--radius-sm);
}

.btn-sm {
  padding: 0.4rem 0.7rem;
  font-size: 0.82rem;
}

.btn-sm i,
.btn-sm svg {
  width: 0.9rem;
  height: 0.9rem;
}

/* ---------- Alerts ---------- */

.alert {
  display: flex;
  align-items: flex-start;
  gap: 0.55rem;
  padding: 0.75rem 1rem;
  border-radius: var(--radius-sm);
  margin-bottom: 1rem;
  font-size: 0.9rem;
}

.alert i,
.alert svg {
  width: 1.1rem;
  height: 1.1rem;
  flex-shrink: 0;
  margin-top: 0.1rem;
}

.alert-error {
  background: var(--danger-soft);
  color: var(--danger);
  border: 1px solid #f5c2c2;
}

/* ---------- Table ---------- */

.table-wrap {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: var(--shadow);
}

table.data-table {
  width: 100%;
  border-collapse: collapse;
  min-width: 560px;
}

.data-table th,
.data-table td {
  padding: 0.85rem 1rem;
  text-align: left;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

.data-table th {
  background: var(--bg-soft);
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
  font-weight: 700;
  white-space: nowrap;
}

.data-table tbody tr:last-child td {
  border-bottom: none;
}

.data-table tbody tr:hover {
  background: rgba(247, 243, 235, 0.65);
}

.sort-link {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  color: inherit;
  text-decoration: none;
}

.sort-link:hover {
  color: var(--text);
  text-decoration: none;
}

.sort-link.is-active {
  color: var(--text);
}

.sort-link i,
.sort-link svg {
  width: 0.9rem;
  height: 0.9rem;
  opacity: 0.7;
}

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  align-items: center;
}

.file-cell {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}

.thumb {
  width: 40px;
  height: 40px;
  border-radius: 8px;
  object-fit: cover;
  border: 1px solid var(--border);
  background: var(--bg-soft);
}

.thumb-placeholder {
  width: 40px;
  height: 40px;
  border-radius: 8px;
  border: 1px dashed var(--border);
  background: var(--bg-soft);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-muted);
}

.thumb-placeholder i,
.thumb-placeholder svg {
  width: 1rem;
  height: 1rem;
}

.muted {
  color: var(--text-muted);
}

.empty-state {
  text-align: center;
  padding: 2.5rem 1rem;
  color: var(--text-muted);
}

.empty-state i,
.empty-state svg {
  width: 2.25rem;
  height: 2.25rem;
  margin: 0 auto 0.75rem;
  color: var(--primary);
  display: block;
}

/* ---------- Image gallery (search + favorites) ---------- */

.section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  flex-wrap: wrap;
  margin: 1.75rem 0 0.85rem;
}

.section-head h2 {
  margin: 0;
  font-size: 1.15rem;
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
}

.section-head h2 i,
.section-head h2 svg {
  width: 1.15rem;
  height: 1.15rem;
  color: var(--primary);
}

.image-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 0.85rem;
}

.image-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow: var(--shadow);
  display: flex;
  flex-direction: column;
  transition: transform 0.15s, box-shadow 0.15s;
}

.image-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 12px 28px rgba(26, 26, 26, 0.1);
}

.image-card__media {
  aspect-ratio: 1;
  background: var(--bg-soft);
  position: relative;
  overflow: hidden;
}

.image-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.image-card__body {
  padding: 0.65rem 0.75rem 0.8rem;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  flex: 1;
}

.image-card__title {
  font-size: 0.82rem;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--text);
}

.image-card__meta {
  font-size: 0.75rem;
  color: var(--text-muted);
}

.image-card__actions {
  display: flex;
  gap: 0.35rem;
  margin-top: 0.25rem;
}

.fav-btn.is-active {
  background: var(--danger-soft);
  color: var(--danger);
  border-color: transparent;
}

.fav-btn.is-active:hover {
  background: var(--danger);
  color: #fff;
}

/* ---------- Utility ---------- */

.back-link {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  color: var(--text-muted);
  font-size: 0.9rem;
  margin-bottom: 1rem;
}

.back-link:hover {
  color: var(--text);
}

.current-file {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.5rem;
  padding: 0.55rem 0.7rem;
  background: var(--bg-soft);
  border-radius: var(--radius-sm);
  font-size: 0.85rem;
}

.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.15rem 0.5rem;
  border-radius: 999px;
  background: var(--bg-soft);
  color: var(--text-muted);
  font-size: 0.75rem;
  font-weight: 600;
}

/* Lucide icons via unpkg use <i data-lucide> which becomes svg */
[data-lucide] {
  width: 1rem;
  height: 1rem;
  stroke-width: 2;
}

/* ---------- Responsive ---------- */

@media (max-width: 640px) {
  .page {
    width: min(100% - 1.25rem, var(--max));
    padding-top: 1rem;
  }

  .site-header__inner {
    width: min(100% - 1.25rem, var(--max));
  }

  .toolbar {
    flex-direction: column;
    align-items: stretch;
  }

  .search-field,
  .sort-field {
    max-width: none;
    width: 100%;
  }

  .page-title {
    flex-direction: column;
    align-items: stretch;
  }

  .page-title .btn {
    width: 100%;
  }

  .image-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .data-table th,
  .data-table td {
    padding: 0.7rem 0.75rem;
  }
}

@media (max-width: 400px) {
  .image-grid {
    grid-template-columns: 1fr;
  }
}

Link (reference): php-app/application/favorites.js

nano application/favorites.js
/**
 * Image favorites — stored in localStorage (no DB change required).
 * Keys: favorite image file paths (e.g. uploads/123_photo.jpg)
 */
(function () {
  var STORAGE_KEY = 'php_user_app_favorites';

  function load() {
    try {
      var raw = localStorage.getItem(STORAGE_KEY);
      var list = raw ? JSON.parse(raw) : [];
      return Array.isArray(list) ? list : [];
    } catch (e) {
      return [];
    }
  }

  function save(list) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
  }

  function isFav(src) {
    return load().indexOf(src) !== -1;
  }

  function toggle(src) {
    var list = load();
    var i = list.indexOf(src);
    if (i === -1) {
      list.push(src);
    } else {
      list.splice(i, 1);
    }
    save(list);
    return i === -1;
  }

  function updateButtons() {
    document.querySelectorAll('[data-fav-src]').forEach(function (btn) {
      var src = btn.getAttribute('data-fav-src');
      var active = isFav(src);
      btn.classList.toggle('is-active', active);
      btn.setAttribute('aria-pressed', active ? 'true' : 'false');
      btn.setAttribute('title', active ? 'Remove from favorites' : 'Add to favorites');
      var label = btn.querySelector('.fav-label');
      if (label) {
        label.textContent = active ? 'Favorited' : 'Favorite';
      }
      // Swap lucide icon name if present
      var icon = btn.querySelector('[data-lucide]');
      if (icon) {
        icon.setAttribute('data-lucide', active ? 'heart' : 'heart');
      }
    });
  }

  function onClick(e) {
    var btn = e.target.closest('[data-fav-src]');
    if (!btn) return;
    e.preventDefault();
    var src = btn.getAttribute('data-fav-src');
    if (!src) return;
    toggle(src);
    updateButtons();
    // Optional: live-filter favorites page
    if (document.body.dataset.page === 'favorites') {
      renderFavoritesPage();
    }
  }

  function renderFavoritesPage() {
    var grid = document.getElementById('favorites-grid');
    var empty = document.getElementById('favorites-empty');
    if (!grid) return;

    var favs = load();
    // Only keep items that still exist in the catalog (if provided)
    var catalog = window.IMAGE_CATALOG || null;
    if (catalog && catalog.length) {
      var allowed = {};
      catalog.forEach(function (item) {
        allowed[item.src] = item;
      });
      favs = favs.filter(function (src) {
        return allowed[src];
      });
      // Persist cleaned list
      save(favs);
    }

    grid.innerHTML = '';
    if (favs.length === 0) {
      if (empty) empty.hidden = false;
      grid.hidden = true;
      return;
    }
    if (empty) empty.hidden = true;
    grid.hidden = false;

    favs.forEach(function (src) {
      var meta = catalog ? catalog.find(function (c) { return c.src === src; }) : null;
      var title = meta ? meta.title : src.split('/').pop();
      var owner = meta ? meta.owner : '';

      var card = document.createElement('article');
      card.className = 'image-card';
      card.innerHTML =
        '<a class="image-card__media" href="' + esc(src) + '" target="_blank" rel="noopener">' +
          '<img src="' + esc(src) + '" alt="' + esc(title) + '" loading="lazy">' +
        '</a>' +
        '<div class="image-card__body">' +
          '<div class="image-card__title" title="' + esc(title) + '">' + esc(title) + '</div>' +
          (owner ? '<div class="image-card__meta">' + esc(owner) + '</div>' : '') +
          '<div class="image-card__actions">' +
            '<button type="button" class="btn btn-sm btn-ghost fav-btn is-active" data-fav-src="' + esc(src) + '">' +
              '<i data-lucide="heart"></i><span class="fav-label">Favorited</span>' +
            '</button>' +
            '<a class="btn btn-sm btn-ghost" href="' + esc(src) + '" target="_blank" rel="noopener">' +
              '<i data-lucide="external-link"></i> Open' +
            '</a>' +
          '</div>' +
        '</div>';
      grid.appendChild(card);
    });

    if (window.lucide) lucide.createIcons();
    updateButtons();
  }

  function esc(s) {
    return String(s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  function filterImages(query) {
    var q = (query || '').trim().toLowerCase();
    document.querySelectorAll('[data-image-card]').forEach(function (card) {
      var hay = (card.getAttribute('data-search') || '').toLowerCase();
      var show = !q || hay.indexOf(q) !== -1;
      card.hidden = !show;
    });
    var empty = document.getElementById('images-empty-filter');
    if (empty) {
      var anyVisible = !!document.querySelector('[data-image-card]:not([hidden])');
      empty.hidden = anyVisible || !q;
    }
  }

  document.addEventListener('DOMContentLoaded', function () {
    document.addEventListener('click', onClick);
    updateButtons();

    var search = document.getElementById('image-search');
    if (search) {
      search.addEventListener('input', function () {
        filterImages(search.value);
      });
    }

    if (document.body.dataset.page === 'favorites') {
      renderFavoritesPage();
    }
  });

  // Expose for optional use
  window.AppFavorites = { load: load, isFav: isFav, toggle: toggle, updateButtons: updateButtons };
})();

File 4 — application/index.php (dashboard: upload + your images)

Link (reference): php-app/application/index.php

nano application/index.php
<?php
// Home page - list users (READ) + image gallery with search & favorites
require 'db.php';

// ---- Sorting (whitelist columns) ----
$allowedSort = [
    'id'    => 'id',
    'name'  => 'name',
    'email' => 'email',
];
$sort = $_GET['sort'] ?? 'id';
if (!isset($allowedSort[$sort])) {
    $sort = 'id';
}
$dir = strtolower($_GET['dir'] ?? 'desc');
$dir = $dir === 'asc' ? 'ASC' : 'DESC';

// ---- User search (name / email) ----
$q = trim($_GET['q'] ?? '');

$sql = 'SELECT * FROM users';
$params = [];
if ($q !== '') {
    $sql .= ' WHERE name LIKE ? OR email LIKE ? OR file_name LIKE ?';
    $like = '%' . $q . '%';
    $params = [$like, $like, $like];
}
$sql .= ' ORDER BY ' . $allowedSort[$sort] . ' ' . $dir;

$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);

// Images catalog for gallery + favorites page
$images = [];
foreach ($users as $u) {
    if (!empty($u['file_name'])) {
        $ext = strtolower(pathinfo($u['file_name'], PATHINFO_EXTENSION));
        $isImage = in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
        if ($isImage) {
            $images[] = [
                'src'   => 'uploads/' . $u['file_name'],
                'title' => $u['file_name'],
                'owner' => $u['name'],
                'email' => $u['email'],
            ];
        }
    }
}

function sortUrl(string $col, string $currentSort, string $currentDir, string $q): string
{
    $dir = ($currentSort === $col && strtoupper($currentDir) === 'ASC') ? 'desc' : 'asc';
    $params = ['sort' => $col, 'dir' => $dir];
    if ($q !== '') {
        $params['q'] = $q;
    }
    return 'index.php?' . http_build_query($params);
}

function sortIcon(string $col, string $currentSort, string $currentDir): string
{
    if ($currentSort !== $col) {
        return 'arrow-up-down';
    }
    return strtoupper($currentDir) === 'ASC' ? 'arrow-up' : 'arrow-down';
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>User Management (PHP)</title>
    <link rel="stylesheet" href="style.css">
    <script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<header class="site-header">
    <div class="site-header__inner">
        <a class="brand" href="index.php">
            <i data-lucide="users"></i>
            User Management
        </a>
        <nav class="nav">
            <a class="is-active" href="index.php"><i data-lucide="list"></i> Users</a>
            <a href="favorites.php"><i data-lucide="heart"></i> Favorites</a>
            <a href="create.php"><i data-lucide="user-plus"></i> Register</a>
        </nav>
    </div>
</header>

<main class="page">
    <div class="page-title">
        <div>
            <h1>Users</h1>
            <p class="lead">PHP + MySQL · sort, search, and favorite uploaded images</p>
        </div>
        <a class="btn btn-primary" href="create.php">
            <i data-lucide="user-plus"></i>
            Register New User
        </a>
    </div>

    <form class="toolbar card" method="GET" action="index.php">
        <div class="search-field">
            <i data-lucide="search"></i>
            <input type="search" name="q" value="<?= htmlspecialchars($q) ?>" placeholder="Search name, email, or file…">
        </div>
        <div class="sort-field">
            <select name="sort" aria-label="Sort by" onchange="this.form.submit()">
                <option value="id" <?= $sort === 'id' ? 'selected' : '' ?>>Sort by ID</option>
                <option value="name" <?= $sort === 'name' ? 'selected' : '' ?>>Sort by Name</option>
                <option value="email" <?= $sort === 'email' ? 'selected' : '' ?>>Sort by Email</option>
            </select>
        </div>
        <div class="sort-field">
            <select name="dir" aria-label="Sort direction" onchange="this.form.submit()">
                <option value="asc" <?= strtoupper($dir) === 'ASC' ? 'selected' : '' ?>>Ascending</option>
                <option value="desc" <?= strtoupper($dir) === 'DESC' ? 'selected' : '' ?>>Descending</option>
            </select>
        </div>
        <button class="btn btn-primary" type="submit">
            <i data-lucide="filter"></i>
            Apply
        </button>
        <?php if ($q !== ''): ?>
            <a class="btn btn-ghost" href="index.php"><i data-lucide="x"></i> Clear</a>
        <?php endif; ?>
        <span class="toolbar-spacer"></span>
        <span class="badge"><i data-lucide="users"></i> <?= count($users) ?> user<?= count($users) === 1 ? '' : 's' ?></span>
    </form>

    <div class="table-wrap">
        <table class="data-table">
            <thead>
                <tr>
                    <th>
                        <a class="sort-link <?= $sort === 'id' ? 'is-active' : '' ?>"
                           href="<?= htmlspecialchars(sortUrl('id', $sort, $dir, $q)) ?>">
                            ID <i data-lucide="<?= sortIcon('id', $sort, $dir) ?>"></i>
                        </a>
                    </th>
                    <th>
                        <a class="sort-link <?= $sort === 'name' ? 'is-active' : '' ?>"
                           href="<?= htmlspecialchars(sortUrl('name', $sort, $dir, $q)) ?>">
                            Name <i data-lucide="<?= sortIcon('name', $sort, $dir) ?>"></i>
                        </a>
                    </th>
                    <th>
                        <a class="sort-link <?= $sort === 'email' ? 'is-active' : '' ?>"
                           href="<?= htmlspecialchars(sortUrl('email', $sort, $dir, $q)) ?>">
                            Email <i data-lucide="<?= sortIcon('email', $sort, $dir) ?>"></i>
                        </a>
                    </th>
                    <th>File</th>
                    <th>Actions</th>
                </tr>
            </thead>
            <tbody>
            <?php if (count($users) === 0): ?>
                <tr>
                    <td colspan="5">
                        <div class="empty-state">
                            <i data-lucide="inbox"></i>
                            <?php if ($q !== ''): ?>
                                No users match “<?= htmlspecialchars($q) ?>”.
                            <?php else: ?>
                                No users yet. Register one!
                            <?php endif; ?>
                        </div>
                    </td>
                </tr>
            <?php endif; ?>
            <?php foreach ($users as $u): ?>
                <?php
                $file = $u['file_name'] ?? null;
                $ext = $file ? strtolower(pathinfo($file, PATHINFO_EXTENSION)) : '';
                $isImage = $file && in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
                $fileSrc = $file ? 'uploads/' . $file : null;
                ?>
                <tr>
                    <td><?= (int)$u['id'] ?></td>
                    <td><?= htmlspecialchars($u['name']) ?></td>
                    <td><?= htmlspecialchars($u['email']) ?></td>
                    <td>
                        <?php if ($fileSrc): ?>
                            <div class="file-cell">
                                <?php if ($isImage): ?>
                                    <a href="<?= htmlspecialchars($fileSrc) ?>" target="_blank" rel="noopener">
                                        <img class="thumb" src="<?= htmlspecialchars($fileSrc) ?>" alt="">
                                    </a>
                                <?php else: ?>
                                    <span class="thumb-placeholder"><i data-lucide="file"></i></span>
                                <?php endif; ?>
                                <a href="<?= htmlspecialchars($fileSrc) ?>" target="_blank" rel="noopener">
                                    View
                                </a>
                                <?php if ($isImage): ?>
                                    <button type="button"
                                            class="btn btn-sm btn-ghost fav-btn"
                                            data-fav-src="<?= htmlspecialchars($fileSrc) ?>"
                                            title="Add to favorites">
                                        <i data-lucide="heart"></i>
                                    </button>
                                <?php endif; ?>
                            </div>
                        <?php else: ?>
                            <span class="muted">—</span>
                        <?php endif; ?>
                    </td>
                    <td>
                        <div class="actions">
                            <a class="btn btn-sm btn-ghost" href="edit.php?id=<?= (int)$u['id'] ?>">
                                <i data-lucide="pencil"></i> Edit
                            </a>
                            <a class="btn btn-sm btn-danger-soft"
                               href="delete.php?id=<?= (int)$u['id'] ?>"
                               onclick="return confirm('Delete this user?')">
                                <i data-lucide="trash-2"></i> Delete
                            </a>
                        </div>
                    </td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>
    </div>

    <div class="section-head">
        <h2><i data-lucide="images"></i> Uploaded images</h2>
        <span class="badge"><?= count($images) ?> image<?= count($images) === 1 ? '' : 's' ?></span>
    </div>

    <div class="toolbar card" style="margin-bottom: 1rem;">
        <div class="search-field">
            <i data-lucide="search"></i>
            <input type="search" id="image-search" placeholder="Search images by file or owner…">
        </div>
        <a class="btn btn-ghost" href="favorites.php">
            <i data-lucide="heart"></i> View favorites
        </a>
    </div>

    <?php if (count($images) === 0): ?>
        <div class="card empty-state">
            <i data-lucide="image-off"></i>
            No image uploads yet. Register a user and attach a picture.
        </div>
    <?php else: ?>
        <div id="images-empty-filter" class="card empty-state" hidden>
            <i data-lucide="search-x"></i>
            No images match your search.
        </div>
        <div class="image-grid" id="image-grid">
            <?php foreach ($images as $img): ?>
                <article class="image-card"
                         data-image-card
                         data-search="<?= htmlspecialchars($img['title'] . ' ' . $img['owner'] . ' ' . $img['email']) ?>">
                    <a class="image-card__media" href="<?= htmlspecialchars($img['src']) ?>" target="_blank" rel="noopener">
                        <img src="<?= htmlspecialchars($img['src']) ?>" alt="<?= htmlspecialchars($img['title']) ?>" loading="lazy">
                    </a>
                    <div class="image-card__body">
                        <div class="image-card__title" title="<?= htmlspecialchars($img['title']) ?>">
                            <?= htmlspecialchars($img['title']) ?>
                        </div>
                        <div class="image-card__meta"><?= htmlspecialchars($img['owner']) ?></div>
                        <div class="image-card__actions">
                            <button type="button"
                                    class="btn btn-sm btn-ghost fav-btn"
                                    data-fav-src="<?= htmlspecialchars($img['src']) ?>">
                                <i data-lucide="heart"></i>
                                <span class="fav-label">Favorite</span>
                            </button>
                            <a class="btn btn-sm btn-ghost" href="<?= htmlspecialchars($img['src']) ?>" target="_blank" rel="noopener">
                                <i data-lucide="external-link"></i>
                            </a>
                        </div>
                    </div>
                </article>
            <?php endforeach; ?>
        </div>
    <?php endif; ?>
</main>

<script>
  window.IMAGE_CATALOG = <?= json_encode($images, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>
<script src="favorites.js"></script>
<script>lucide.createIcons();</script>
</body>
</html>

File 5 — application/create.php (CREATE + upload)

Link (reference): php-app/application/create.php

nano application/create.php
<?php
// CREATE - register a new user + optional file upload
require 'db.php';

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $name     = trim($_POST['name'] ?? '');
    $email    = trim($_POST['email'] ?? '');
    $password = trim($_POST['password'] ?? '');
    $fileName = null;

    if ($name === '' || $email === '' || $password === '') {
        $error = 'Name, email and password are required.';
    } else {
        // Handle optional file upload
        if (!empty($_FILES['file']['name']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
            $original = basename($_FILES['file']['name']);
            $fileName = time() . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '', $original);
            $dest = __DIR__ . '/uploads/' . $fileName;
            if (!is_dir(__DIR__ . '/uploads')) {
                mkdir(__DIR__ . '/uploads', 0755, true);
            }
            if (!move_uploaded_file($_FILES['file']['tmp_name'], $dest)) {
                $error = 'File upload failed.';
            }
        }

        if ($error === '') {
            // Hash password before saving (bcrypt via password_hash)
            $hashed = password_hash($password, PASSWORD_DEFAULT);
            $stmt = $pdo->prepare(
                "INSERT INTO users (name, email, password, file_name) VALUES (?, ?, ?, ?)"
            );
            try {
                $stmt->execute([$name, $email, $hashed, $fileName]);
                header('Location: index.php');
                exit;
            } catch (PDOException $e) {
                $error = 'Could not save user. Email may already exist.';
            }
        }
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Register User</title>
    <link rel="stylesheet" href="style.css">
    <script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<header class="site-header">
    <div class="site-header__inner">
        <a class="brand" href="index.php">
            <i data-lucide="users"></i>
            User Management
        </a>
        <nav class="nav">
            <a href="index.php"><i data-lucide="list"></i> Users</a>
            <a href="favorites.php"><i data-lucide="heart"></i> Favorites</a>
            <a class="is-active" href="create.php"><i data-lucide="user-plus"></i> Register</a>
        </nav>
    </div>
</header>

<main class="page">
    <a class="back-link" href="index.php">
        <i data-lucide="arrow-left"></i> Back to list
    </a>

    <div class="page-title">
        <div>
            <h1>Register New User</h1>
            <p class="lead">Create an account and optionally upload a file or profile image.</p>
        </div>
    </div>

    <div class="card">
        <?php if ($error): ?>
            <div class="alert alert-error">
                <i data-lucide="alert-circle"></i>
                <span><?= htmlspecialchars($error) ?></span>
            </div>
        <?php endif; ?>

        <form method="POST" enctype="multipart/form-data" class="form-stack">
            <div>
                <label for="name">Name</label>
                <input id="name" type="text" name="name" required
                       value="<?= htmlspecialchars($_POST['name'] ?? '') ?>">
            </div>

            <div>
                <label for="email">Email</label>
                <input id="email" type="email" name="email" required
                       value="<?= htmlspecialchars($_POST['email'] ?? '') ?>">
            </div>

            <div>
                <label for="password">Password</label>
                <input id="password" type="password" name="password" required>
            </div>

            <div>
                <label for="file">Upload File <span class="field-hint" style="display:inline">(optional)</span></label>
                <input id="file" type="file" name="file" accept="image/*,.pdf,.doc,.docx,.txt">
                <span class="field-hint">Images can be searched and favorited on the home page.</span>
            </div>

            <div class="actions">
                <button class="btn btn-primary" type="submit">
                    <i data-lucide="user-plus"></i>
                    Register
                </button>
                <a class="btn btn-ghost" href="index.php">Cancel</a>
            </div>
        </form>
    </div>
</main>

<script>lucide.createIcons();</script>
</body>
</html>

File 6 — application/edit.php (UPDATE)

Link (reference): php-app/application/edit.php

nano application/edit.php
<?php
// UPDATE - edit an existing user
require 'db.php';

$id = (int)($_GET['id'] ?? 0);
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$user) {
    http_response_code(404);
    ?>
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>User not found</title>
        <link rel="stylesheet" href="style.css">
        <script src="https://unpkg.com/lucide@latest"></script>
    </head>
    <body>
    <main class="page">
        <div class="card empty-state">
            <i data-lucide="user-x"></i>
            <p>User not found.</p>
            <a class="btn btn-primary" href="index.php"><i data-lucide="arrow-left"></i> Go back</a>
        </div>
    </main>
    <script>lucide.createIcons();</script>
    </body>
    </html>
    <?php
    exit;
}

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $name     = trim($_POST['name'] ?? '');
    $email    = trim($_POST['email'] ?? '');
    $password = trim($_POST['password'] ?? '');
    $fileName = $user['file_name'];

    if ($name === '' || $email === '') {
        $error = 'Name and email are required.';
    } else {
        // Optional new file upload
        if (!empty($_FILES['file']['name']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
            $original = basename($_FILES['file']['name']);
            $fileName = time() . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '', $original);
            $dest = __DIR__ . '/uploads/' . $fileName;
            if (!is_dir(__DIR__ . '/uploads')) {
                mkdir(__DIR__ . '/uploads', 0755, true);
            }
            move_uploaded_file($_FILES['file']['tmp_name'], $dest);
        }

        // Keep old hash if password left blank; otherwise hash the new one
        if ($password !== '') {
            $hashed = password_hash($password, PASSWORD_DEFAULT);
        } else {
            $hashed = $user['password'];
        }

        $stmt = $pdo->prepare(
            "UPDATE users SET name = ?, email = ?, password = ?, file_name = ? WHERE id = ?"
        );
        try {
            $stmt->execute([$name, $email, $hashed, $fileName, $id]);
            header('Location: index.php');
            exit;
        } catch (PDOException $e) {
            $error = 'Update failed. Email may already be used.';
        }
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Edit User</title>
    <link rel="stylesheet" href="style.css">
    <script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<header class="site-header">
    <div class="site-header__inner">
        <a class="brand" href="index.php">
            <i data-lucide="users"></i>
            User Management
        </a>
        <nav class="nav">
            <a href="index.php"><i data-lucide="list"></i> Users</a>
            <a href="favorites.php"><i data-lucide="heart"></i> Favorites</a>
            <a href="create.php"><i data-lucide="user-plus"></i> Register</a>
        </nav>
    </div>
</header>

<main class="page">
    <a class="back-link" href="index.php">
        <i data-lucide="arrow-left"></i> Back to list
    </a>

    <div class="page-title">
        <div>
            <h1>Edit User #<?= (int)$user['id'] ?></h1>
            <p class="lead">Update profile details. Leave password blank to keep the current one.</p>
        </div>
    </div>

    <div class="card">
        <?php if ($error): ?>
            <div class="alert alert-error">
                <i data-lucide="alert-circle"></i>
                <span><?= htmlspecialchars($error) ?></span>
            </div>
        <?php endif; ?>

        <form method="POST" enctype="multipart/form-data" class="form-stack">
            <div>
                <label for="name">Name</label>
                <input id="name" type="text" name="name" required
                       value="<?= htmlspecialchars($user['name']) ?>">
            </div>

            <div>
                <label for="email">Email</label>
                <input id="email" type="email" name="email" required
                       value="<?= htmlspecialchars($user['email']) ?>">
            </div>

            <div>
                <label for="password">New Password</label>
                <input id="password" type="password" name="password" placeholder="Leave blank to keep current">
                <span class="field-hint">Only fill this if you want to change the password.</span>
            </div>

            <div>
                <label for="file">Upload New File <span class="field-hint" style="display:inline">(optional)</span></label>
                <input id="file" type="file" name="file" accept="image/*,.pdf,.doc,.docx,.txt">
                <?php if ($user['file_name']): ?>
                    <div class="current-file">
                        <i data-lucide="paperclip"></i>
                        Current:
                        <a href="uploads/<?= htmlspecialchars($user['file_name']) ?>" target="_blank" rel="noopener">
                            <?= htmlspecialchars($user['file_name']) ?>
                        </a>
                    </div>
                <?php endif; ?>
            </div>

            <div class="actions">
                <button class="btn btn-primary" type="submit">
                    <i data-lucide="save"></i>
                    Save Changes
                </button>
                <a class="btn btn-ghost" href="index.php">Cancel</a>
            </div>
        </form>
    </div>
</main>

<script>lucide.createIcons();</script>
</body>
</html>

File 7 — application/delete.php (DELETE)

Link (reference): php-app/application/delete.php

nano application/delete.php
<?php
// DELETE - remove a user
require 'db.php';

$id = (int)($_GET['id'] ?? 0);

// Get file name so we can remove the file too
$stmt = $pdo->prepare("SELECT file_name FROM users WHERE id = ?");
$stmt->execute([$id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);

if ($user) {
    // Delete uploaded file from disk if it exists
    if ($user['file_name']) {
        $path = __DIR__ . '/uploads/' . $user['file_name'];
        if (file_exists($path)) {
            unlink($path);
        }
    }
    // Delete row from database
    $pdo->prepare("DELETE FROM users WHERE id = ?")->execute([$id]);
}

header('Location: index.php');
exit;

File 8 — application/favorites.php (favorite images)

Link (reference): php-app/application/favorites.php

nano application/favorites.php
<?php
// Favorites page — shows images the user favorited (localStorage + server catalog)
require 'db.php';

$rows = $pdo->query(
    "SELECT name, email, file_name FROM users WHERE file_name IS NOT NULL AND file_name != ''"
)->fetchAll(PDO::FETCH_ASSOC);

$images = [];
foreach ($rows as $u) {
    $ext = strtolower(pathinfo($u['file_name'], PATHINFO_EXTENSION));
    if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true)) {
        $images[] = [
            'src'   => 'uploads/' . $u['file_name'],
            'title' => $u['file_name'],
            'owner' => $u['name'],
            'email' => $u['email'],
        ];
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Favorite Images</title>
    <link rel="stylesheet" href="style.css">
    <script src="https://unpkg.com/lucide@latest"></script>
</head>
<body data-page="favorites">
<header class="site-header">
    <div class="site-header__inner">
        <a class="brand" href="index.php">
            <i data-lucide="users"></i>
            User Management
        </a>
        <nav class="nav">
            <a href="index.php"><i data-lucide="list"></i> Users</a>
            <a class="is-active" href="favorites.php"><i data-lucide="heart"></i> Favorites</a>
            <a href="create.php"><i data-lucide="user-plus"></i> Register</a>
        </nav>
    </div>
</header>

<main class="page">
    <div class="page-title">
        <div>
            <h1>Favorite images</h1>
            <p class="lead">Images you marked with a heart. Stored in this browser.</p>
        </div>
        <a class="btn btn-ghost" href="index.php">
            <i data-lucide="images"></i>
            Browse all images
        </a>
    </div>

    <div id="favorites-empty" class="card empty-state">
        <i data-lucide="heart"></i>
        <p>No favorites yet.</p>
        <p class="muted">Open the Users page, search the image gallery, and tap <strong>Favorite</strong>.</p>
        <a class="btn btn-primary" href="index.php#image-grid" style="margin-top: 0.75rem;">
            <i data-lucide="search"></i>
            Search images
        </a>
    </div>

    <div class="image-grid" id="favorites-grid" hidden></div>
</main>

<script>
  window.IMAGE_CATALOG = <?= json_encode($images, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script>
<script src="favorites.js"></script>
<script>lucide.createIcons();</script>
</body>
</html>

Check files exist

ls -la application/

# db.php  auth.php  style.css  favorites.js  index.php  login.php  register.php  logout.php  edit.php  delete.php  favorites.php

A3. Create Dockerfile piece by piece

Link (reference): php-app/Dockerfile

cd ~/docker-task-php
nano Dockerfile

Piece 1

FROM php:8.2-apache

Piece 2

RUN docker-php-ext-install pdo pdo_mysql

Piece 3 — COPY your code into the image (this replaces “mounting”)

COPY application/ /var/www/html/

Piece 4

RUN mkdir -p /var/www/html/uploads && chown -R www-data:www-data /var/www/html/uploads

Full Dockerfile

FROM php:8.2-apache
RUN docker-php-ext-install pdo pdo_mysql
COPY application/ /var/www/html/
RUN mkdir -p /var/www/html/uploads && chown -R www-data:www-data /var/www/html/uploads


A4. Create .env, Compose, and other setup files (before running anything)

Do this in ~/docker-task-php before docker pull / docker run / compose up.

Reference: .env.example · docker-compose.yml · .dockerignore

A4.1 — .env

cd ~/docker-task-php
nano .env
MYSQL_ROOT_PASSWORD=rootpass
DB_HOST=db
DB_NAME=userdb
DB_USER=appuser
DB_PASS=apppass
APP_PORT=8080
DB_PORT=3306

Do not commit secrets publicly; keep a template as .env.example.

A4.2 — .dockerignore

nano .dockerignore
.env
.git
uploads
*.md

A4.3 — docker-compose.yml

nano docker-compose.yml
services:
  app:
    build: .
    image: docker-task-php
    container_name: docker-task-app
    ports:
      - "${APP_PORT:-8080}:80"
    env_file:
      - .env
    environment:
      DB_HOST: ${DB_HOST:-db}
      DB_NAME: ${DB_NAME:-userdb}
      DB_USER: ${DB_USER:-appuser}
      DB_PASS: ${DB_PASS:-apppass}
    volumes:
      - docker-task-uploads:/var/www/html/uploads
    depends_on:
      - db
    networks:
      - docker-task-net
  db:
    image: mysql:8.0
    container_name: docker-task-db
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpass}
    volumes:
      - docker-task-mysql:/var/lib/mysql
    ports:
      - "${DB_PORT:-3306}:3306"
    networks:
      docker-task-net:
        aliases:
          - db
networks:
  docker-task-net:
    name: docker-task-net
    driver: bridge
volumes:
  docker-task-mysql:
    name: docker-task-mysql
  docker-task-uploads:
    name: docker-task-uploads
PieceRole
.envPasswords, DB name, host ports
env_file / environmentPass DB_* into PHP (getenv)
networks + name:Custom bridge; hostname db
volumes + name:Named volumes (same names as manual CLI)
.dockerignoreKeep .env out of the image
ls -la
# expect: .env  .dockerignore  docker-compose.yml  Dockerfile  application/

Manual CLI: A5–A11 · Compose shortcut: A12. Create A4 files first — before any containers start.

A5. Pull images by command

docker pull mysql:8.0
docker pull php:8.2-apache
docker images

A6. Create network by command

docker network create docker-task-net
docker network ls
docker network inspect docker-task-net

A7. Create named volumes by command (no host bind mounts)

docker volume create docker-task-mysql
docker volume create docker-task-uploads
docker volume ls
docker volume inspect docker-task-mysql
docker volume inspect docker-task-uploads
VolumeUsed for
docker-task-mysqlMySQL data files
docker-task-uploadsUser uploaded files

A8. Build your image (copies code in)

cd ~/docker-task-php
docker build -t docker-task-php .
docker images

If you change any PHP file later: edit the file → run docker build -t docker-task-php . again → recreate the app container.


A9. Run MySQL (manual)

docker run -d \
  --name docker-task-db \
  --network docker-task-net \
  --network-alias db \
  -e MYSQL_ROOT_PASSWORD=rootpass \
  -v docker-task-mysql:/var/lib/mysql \
  -p 3306:3306 \
  mysql:8.0

docker logs -f docker-task-db
# wait for "ready for connections", then Ctrl+C

A10. Create database (SQLyog)

Connect: host 127.0.0.1, user root, password rootpass, port 3306.

Full guide: setup-database.html

CREATE DATABASE IF NOT EXISTS userdb
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE USER IF NOT EXISTS 'appuser'@'%' IDENTIFIED BY 'apppass';
GRANT ALL PRIVILEGES ON userdb.* TO 'appuser'@'%';
FLUSH PRIVILEGES;

USE userdb;
CREATE TABLE IF NOT EXISTS users (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(100)  NOT NULL,
  email      VARCHAR(100)  NOT NULL UNIQUE,
  password   VARCHAR(255)  NOT NULL,
  created_at TIMESTAMP     DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS images (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  user_id    INT           NOT NULL,
  file_name  VARCHAR(255)  NOT NULL,
  title      VARCHAR(255)  NOT NULL,
  created_at TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_images_user
    FOREIGN KEY (user_id) REFERENCES users (id)
    ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

A11. Run PHP app

docker run -d \
  --name docker-task-app \
  --network docker-task-net \
  -e DB_HOST=db \
  -e DB_NAME=userdb \
  -e DB_USER=appuser \
  -e DB_PASS=apppass \
  -v docker-task-uploads:/var/www/html/uploads \
  -p 8080:80 \
  docker-task-php

docker ps
docker network inspect docker-task-net

Browser: http://localhost:8080

List uploads inside the named volume:

docker run --rm -v docker-task-uploads:/data alpine ls -la /data


A12. Optional — start everything with Compose

Only after A4 exists. Stop manual containers first if needed:

docker rm -f docker-task-app docker-task-db
cd ~/docker-task-php
docker compose config
docker compose up --build -d
docker compose ps
docker compose logs -f db

Browser: http://localhost:8080

docker compose down
docker compose down -v

Same network/volume names: docker-task-net, docker-task-mysql, docker-task-uploads.

A13. Persistence proof

docker rm -f docker-task-app docker-task-db

docker run -d --name docker-task-db --network docker-task-net --network-alias db \
  -e MYSQL_ROOT_PASSWORD=rootpass \
  -v docker-task-mysql:/var/lib/mysql -p 3306:3306 mysql:8.0

# wait for MySQL ready, then:
docker run -d --name docker-task-app --network docker-task-net \
  -e DB_HOST=db -e DB_NAME=userdb -e DB_USER=appuser -e DB_PASS=apppass \
  -v docker-task-uploads:/var/www/html/uploads -p 8080:80 docker-task-php

Refresh browser → same data (volumes kept the data; you did not re-mount a host folder).


A14. Cleanup

docker rm -f docker-task-app docker-task-db
docker volume rm docker-task-mysql docker-task-uploads
docker network rm docker-task-net
docker rmi docker-task-php

Linked source files (quick index)

PHP — create these yourself

You createOpen / copy from
application/db.phpphp-app/application/db.php
application/auth.phpphp-app/application/auth.php
application/style.cssphp-app/application/style.css
application/favorites.jsphp-app/application/favorites.js
application/index.phpphp-app/application/index.php
application/login.phpphp-app/application/login.php
application/register.phpphp-app/application/register.php
application/logout.phpphp-app/application/logout.php
application/edit.phpphp-app/application/edit.php
application/delete.phpphp-app/application/delete.php
application/favorites.phpphp-app/application/favorites.php
Dockerfilephp-app/Dockerfile

Database SQL

TaskFile
Create DB / user / tablessetup-database.html · setup.sql

What you prove for the assignment

ConceptHow you prove it manually
Imagesdocker pull + docker images + your docker build image
DockerfileYou wrote it; COPY puts code in the image
Containersdocker run / docker ps / docker rm
Volumesdocker volume create + -v name:/path (not host bind mount)
Networkingdocker network create + --network-alias db + DB_HOST=db
PersistenceDelete containers, re-run with same volumes

Rules for this project (summary)

DoDon’t
Create every .php / .js file yourselfRely on compose as the main demo
COPY code in Dockerfile + docker buildBind-mount source code with -v ./app:...
docker volume create for MySQL + uploadsOnly use a host ./uploads folder as the “volume story”
docker pull then docker runHide everything behind one compose up

You are building, pulling, networking, and volumizing by hand. That is the whole point.