Manual Docker only — create code yourself, then build & run
This is the only way documented for this project.
You will:
- Create folders and every source file yourself (PHP or Node)
- Paste code from the linked files (or from the blocks below)
- Write a Dockerfile piece by piece
docker pullimagesdocker network createdocker volume create(named volumes only — no host folder bind mounts)docker build(code goes into the image withCOPY)docker runMySQL + 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
| Stack | Working folder you will create | Browser | MySQL port |
|---|---|---|---|
| PHP | ~/docker-task-php (or any empty folder) | http://localhost:8080 | 3306 |
| Node | ~/assignment-docker-node | http://localhost:3000 | 3307 |
Reference copies of the finished code already live under:
- PHP:
php-app/application/ - Node:
nodejs-app/application/(server.js+public/style.css),nodejs-app/database.sql
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:
- Create folders and every source file yourself (PHP or Node)
- Paste code from the linked files (or from the blocks below)
- Write a Dockerfile piece by piece
docker pullimagesdocker network createdocker volume create(named volumes only — no host folder bind mounts)docker build(code goes into the image withCOPY)docker runMySQL + 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
| Stack | Working folder you will create | Browser | MySQL port |
|---|---|---|---|
| PHP | ~/docker-task-php (or any empty folder) | http://localhost:8080 | 3306 |
| Node | ~/assignment-docker-node | http://localhost:3000 | 3307 |
Reference copies of the finished code already live under:
- PHP:
php-app/application/ - Node:
nodejs-app/application/(server.js+public/style.css),nodejs-app/database.sql
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;
}
}
File 3 — application/favorites.js (image favorites + client search)
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
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
| Piece | Role |
|---|---|
.env | Passwords, DB name, host ports |
env_file / environment | Pass DB_* into PHP (getenv) |
networks + name: | Custom bridge; hostname db |
volumes + name: | Named volumes (same names as manual CLI) |
.dockerignore | Keep .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
| Volume | Used for |
|---|---|
docker-task-mysql | MySQL data files |
docker-task-uploads | User 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 create | Open / copy from |
|---|---|
application/db.php | php-app/application/db.php |
application/auth.php | php-app/application/auth.php |
application/style.css | php-app/application/style.css |
application/favorites.js | php-app/application/favorites.js |
application/index.php | php-app/application/index.php |
application/login.php | php-app/application/login.php |
application/register.php | php-app/application/register.php |
application/logout.php | php-app/application/logout.php |
application/edit.php | php-app/application/edit.php |
application/delete.php | php-app/application/delete.php |
application/favorites.php | php-app/application/favorites.php |
Dockerfile | php-app/Dockerfile |
Database SQL
| Task | File |
|---|---|
| Create DB / user / tables | setup-database.html · setup.sql |
What you prove for the assignment
| Concept | How you prove it manually |
|---|---|
| Images | docker pull + docker images + your docker build image |
| Dockerfile | You wrote it; COPY puts code in the image |
| Containers | docker run / docker ps / docker rm |
| Volumes | docker volume create + -v name:/path (not host bind mount) |
| Networking | docker network create + --network-alias db + DB_HOST=db |
| Persistence | Delete containers, re-run with same volumes |
Rules for this project (summary)
| Do | Don’t |
|---|---|
Create every .php / .js file yourself | Rely on compose as the main demo |
COPY code in Dockerfile + docker build | Bind-mount source code with -v ./app:... |
docker volume create for MySQL + uploads | Only use a host ./uploads folder as the “volume story” |
docker pull then docker run | Hide everything behind one compose up |
You are building, pulling, networking, and volumizing by hand. That is the whole point.