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: PHP manual → · back to overview

PATH B — Node.js (create everything manually)

B1. Create project folder

mkdir -p ~/assignment-docker-node/application/public
cd ~/assignment-docker-node

Layout:

assignment-docker-node/
├── .env                ← you create (secrets / ports)
├── .env.example        ← optional template
├── .dockerignore       ← you create
├── docker-compose.yml  ← you create
├── Dockerfile          ← you create
├── database.sql        ← you create (copy from nodejs-app/database.sql)
├── package.json        ← you create
└── application/
    ├── public/
    │   └── style.css   ← you create (PicVault light theme)
    └── server.js       ← you create (entire app)

This is PicVault — a private image gallery: register, log in, upload images, rename titles, delete images. Every user only sees and manages their own images.

UI notes (Node path):

  • design.md light theme — Notion-inspired: warm paper canvas #f6f5f4, white cards, one blue accent #0075de for CTAs and links
  • Inter font from Google Fonts; one global stylesheet: application/public/style.css
  • Login first — visitors land on /login; new users can /register
  • Private gallery — after login each user only sees their own uploaded images at /home
  • Upload — image-only form (JPG, PNG, GIF, WEBP; max 10 MB)
  • Rename and delete — inline controls on each image card
  • Sessions — signed cookie (picvault_session); SESSION_SECRET env var

B2. Create Node files one by one

File 1 — package.json

Link: nodejs-app/package.json

nano package.json
{
  "name": "user-management-nodejs",
  "version": "1.0.0",
  "description": "Beginner Dockerized user management app",
  "main": "server.js",
  "dependencies": {
    "express": "^5.2.1",
    "multer": "^2.2.0",
    "mysql2": "^3.23.2"
  }
}

File 2 — application/server.js (entire app — PicVault)

Link: nodejs-app/application/server.js

nano application/server.js

Paste the full contents of the linked file (routes, auth, uploads). Open the link in your editor and copy the whole file if paste from here is hard. Full code:

// PicVault — Node.js + Express + MySQL image gallery
// Register, log in, upload images, rename titles, delete images.
// Light theme only — one global stylesheet: application/public/style.css

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const mysql = require('mysql2/promise');

const app = express();
const PORT = process.env.PORT || 3000;

const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
const SESSION_SECRET = process.env.SESSION_SECRET || 'picvault-dev-secret';
const COOKIE_NAME = 'picvault_session';
const SESSION_DAYS = 7;
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;

if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });

// ---------- Password hashing (scrypt — no extra packages) ----------
function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.scryptSync(password, salt, 64).toString('hex');
  return salt + ':' + hash;
}

function verifyPassword(password, stored) {
  const parts = String(stored).split(':');
  if (parts.length !== 2) return false;
  const test = crypto.scryptSync(password, parts[0], 64).toString('hex');
  return crypto.timingSafeEqual(Buffer.from(test, 'hex'), Buffer.from(parts[1], 'hex'));
}

// ---------- Signed cookie sessions (HMAC, no extra packages) ----------
function signSession(payload) {
  const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
  const sig = crypto.createHmac('sha256', SESSION_SECRET).update(data).digest('base64url');
  return data + '.' + sig;
}

function readSession(raw) {
  if (!raw) return null;
  const parts = String(raw).split('.');
  if (parts.length !== 2) return null;
  const expect = crypto.createHmac('sha256', SESSION_SECRET).update(parts[0]).digest();
  const got = Buffer.from(parts[1], 'base64url');
  if (expect.length !== got.length || !crypto.timingSafeEqual(expect, got)) return null;
  try {
    const payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
    if (!payload.uid || !payload.exp || payload.exp < Date.now()) return null;
    return payload;
  } catch {
    return null;
  }
}

function parseCookies(header) {
  const out = {};
  if (!header) return out;
  for (const part of String(header).split(';')) {
    const i = part.indexOf('=');
    if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
  }
  return out;
}

function sessionCookie(payload, maxAgeSec) {
  return `${COOKIE_NAME}=${signSession(payload)}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${maxAgeSec}`;
}

// ---------- Database ----------
const dbConfig = {
  host: process.env.DB_HOST || 'db',
  user: process.env.DB_USER || 'appuser',
  password: process.env.DB_PASS || 'apppass',
  database: process.env.DB_NAME || 'userdb'
};

let pool;

async function initDb() {
  for (let i = 0; i < 30; i++) {
    try {
      pool = mysql.createPool(dbConfig);
      await pool.query('SELECT 1');
      console.log('Connected to MySQL');
      return;
    } catch (err) {
      console.log('Waiting for MySQL... (' + (i + 1) + '/30) — ' + err.message);
      await new Promise(r => setTimeout(r, 2000));
    }
  }
  throw new Error('Could not connect to MySQL. Run database.sql first (see README.md).');
}

// ---------- File upload (images only) ----------
const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, UPLOADS_DIR),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname).toLowerCase();
    const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 80) || 'image';
    cb(null, Date.now() + '_' + base + ext);
  }
});

const upload = multer({
  storage,
  limits: { fileSize: MAX_UPLOAD_BYTES },
  fileFilter: (req, file, cb) => {
    const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    const ok = allowed.includes(file.mimetype);
    cb(ok ? null : new Error('Only JPG, PNG, GIF and WEBP images are allowed.'), ok);
  }
});

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

function fmtBytes(n) {
  if (n < 1024) return n + ' B';
  if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
  return (n / (1024 * 1024)).toFixed(1) + ' MB';
}

function fmtDate(d) {
  const dt = new Date(d);
  return dt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}

function fmtType(fileName) {
  return String(fileName).split('.').pop().toUpperCase();
}

// ---------- Layout ----------
function page(title, body) {
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>
${body}
</body>
</html>`;
}

function brand() {
  return `<a class="brand" href="/home"><span class="brand-mark"></span><span class="brand-name">PicVault</span></a>`;
}

function navBar(user) {
  return `<nav class="nav-bar">
  <div class="nav-inner">
    ${brand()}
    <div class="nav-right">
      <span class="nav-user eyebrow">Hello, <strong>${esc(user.name)}</strong></span>
      <form method="post" action="/logout">
        <button class="btn btn-utility" type="submit">Log out</button>
      </form>
    </div>
  </div>
</nav>`;
}

function toast(req) {
  const msg = req.query.msg;
  const err = req.query.err;
  if (!msg && !err) return '';
  const kind = err ? 'toast-error' : 'toast-success';
  const text = err || msg;
  return `<div class="toast ${kind}"><span class="toast-dot"></span><span>${esc(text)}</span></div>`;
}

function footer() {
  return `<footer class="footer">PicVault — a Node.js + Express + MySQL gallery. Uploads are stored persistently.</footer>`;
}

// ---------- Middleware: load logged-in user ----------
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(UPLOADS_DIR));

app.use(async (req, res, next) => {
  req.user = null;
  const session = readSession(parseCookies(req.headers.cookie)[COOKIE_NAME]);
  if (session) {
    try {
      const [rows] = await pool.query('SELECT id, name, email FROM users WHERE id = ?', [session.uid]);
      if (rows[0]) req.user = rows[0];
    } catch (err) {
      console.error(err.message);
    }
  }
  next();
});

function requireAuth(req, res, next) {
  if (!req.user) return res.redirect('/login');
  next();
}

function uploadSingle(field) {
  return (req, res, next) => {
    upload.single(field)(req, res, err => {
      if (err) {
        const msg = err.code === 'LIMIT_FILE_SIZE'
          ? 'Image is too large — maximum size is 10 MB.'
          : err.message;
        return res.redirect('/home?err=' + encodeURIComponent(msg));
      }
      next();
    });
  };
}

// ================= ROUTES =================

// ---------- Landing ----------
app.get('/', (req, res) => {
  res.redirect(req.user ? '/home' : '/login');
});

// ---------- Login ----------
app.get('/login', (req, res) => {
  if (req.user) return res.redirect('/home');
  res.send(page('Log in — PicVault', `
<div class="auth-wrap">
  <div class="auth-brand">${brand()}</div>
  <div class="card auth-card">
    <div class="sticker-row">
      <span class="sticker" style="background:#62aef0"></span>
      <span class="sticker" style="background:#d6b6f6"></span>
      <span class="sticker" style="background:#ff64c8"></span>
      <span class="sticker" style="background:#2a9d99"></span>
      <span class="sticker" style="background:#1aae39"></span>
    </div>
    <h1>Log in</h1>
    <p class="muted">Welcome back — your images are waiting.</p>
    ${req.query.err ? `<div class="toast toast-error"><span class="toast-dot"></span><span>${esc(req.query.err)}</span></div>` : ''}
    <form method="post" action="/login">
      <div class="field">
        <label for="email">Email</label>
        <input class="text-input" id="email" type="email" name="email" placeholder="you@example.com" required autofocus>
      </div>
      <div class="field">
        <label for="password">Password</label>
        <input class="text-input" id="password" type="password" name="password" placeholder="••••••••" required>
      </div>
      <button class="btn btn-primary btn-block" type="submit" style="margin-top:4px">Log in</button>
    </form>
    <p class="footnote" style="text-align:center">New to PicVault? <a href="/register">Create an account</a></p>
  </div>
  <p class="footer-note">Node.js + Express + MySQL — persistent storage, light theme only.</p>
</div>`));
});

app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.redirect('/login?err=' + encodeURIComponent('Email and password are required.'));
  }
  try {
    const [rows] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
    const user = rows[0];
    if (!user || !verifyPassword(password, user.password)) {
      return res.redirect('/login?err=' + encodeURIComponent('Incorrect email or password.'));
    }
    const maxAge = SESSION_DAYS * 86400;
    res.setHeader('Set-Cookie', sessionCookie({ uid: user.id, exp: Date.now() + maxAge * 1000 }, maxAge));
    res.redirect('/home');
  } catch (err) {
    console.error(err);
    res.redirect('/login?err=' + encodeURIComponent('Something went wrong. Please try again.'));
  }
});

// ---------- Register ----------
app.get('/register', (req, res) => {
  if (req.user) return res.redirect('/home');
  res.send(page('Create account — PicVault', `
<div class="auth-wrap">
  <div class="auth-brand">${brand()}</div>
  <div class="card auth-card">
    <div class="sticker-row">
      <span class="sticker" style="background:#ff64c8"></span>
      <span class="sticker" style="background:#dd5b00"></span>
      <span class="sticker" style="background:#d6b6f6"></span>
      <span class="sticker" style="background:#1aae39"></span>
      <span class="sticker" style="background:#62aef0"></span>
    </div>
    <h1>Create your account</h1>
    <p class="muted">Register in a few seconds and start your gallery.</p>
    ${req.query.err ? `<div class="toast toast-error"><span class="toast-dot"></span><span>${esc(req.query.err)}</span></div>` : ''}
    <form method="post" action="/register">
      <div class="field">
        <label for="name">Name</label>
        <input class="text-input" id="name" type="text" name="name" placeholder="Jane Doe" required autofocus>
      </div>
      <div class="field">
        <label for="email">Email</label>
        <input class="text-input" id="email" type="email" name="email" placeholder="you@example.com" required>
      </div>
      <div class="field">
        <label for="password">Password</label>
        <input class="text-input" id="password" type="password" name="password" placeholder="At least 6 characters" minlength="6" required>
      </div>
      <button class="btn btn-primary btn-block" type="submit" style="margin-top:4px">Create account</button>
    </form>
    <p class="footnote" style="text-align:center">Already have an account? <a href="/login">Log in</a></p>
  </div>
  <p class="footer-note">Node.js + Express + MySQL — persistent storage, light theme only.</p>
</div>`));
});

app.post('/register', async (req, res) => {
  const { name, email, password } = req.body;
  if (!name || !email || !password || password.length < 6) {
    return res.redirect('/register?err=' + encodeURIComponent('All fields are required and the password must be at least 6 characters.'));
  }
  try {
    const hashed = hashPassword(password);
    const [result] = await pool.query(
      'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',
      [name.trim(), email.trim().toLowerCase(), hashed]
    );
    const maxAge = SESSION_DAYS * 86400;
    res.setHeader('Set-Cookie', sessionCookie({ uid: result.insertId, exp: Date.now() + maxAge * 1000 }, maxAge));
    res.redirect('/home?msg=' + encodeURIComponent('Account created — welcome to PicVault!'));
  } catch (err) {
    if (err.code === 'ER_DUP_ENTRY') {
      return res.redirect('/register?err=' + encodeURIComponent('That email is already registered. Try logging in instead.'));
    }
    console.error(err);
    res.redirect('/register?err=' + encodeURIComponent('Something went wrong. Please try again.'));
  }
});

// ---------- Logout ----------
app.post('/logout', (req, res) => {
  res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`);
  res.redirect('/login?msg=' + encodeURIComponent('You have been logged out.'));
});

// ---------- Home (image gallery) ----------
app.get('/home', requireAuth, async (req, res) => {
  try {
    const [images] = await pool.query(
      'SELECT * FROM images WHERE user_id = ? ORDER BY created_at DESC, id DESC',
      [req.user.id]
    );

    let cards = '';
    if (images.length === 0) {
      cards = `<div class="card empty-state">
        <div class="empty-tile"></div>
        <h3>No images yet</h3>
        <p class="muted">Upload your first image above and it will appear here, ready to rename or delete.</p>
      </div>`;
    } else {
      for (const img of images) {
        let meta = fmtType(img.file_name);
        try {
          const stat = fs.statSync(path.join(UPLOADS_DIR, img.file_name));
          meta += ' · ' + fmtBytes(stat.size);
        } catch {}
        meta += ' · ' + fmtDate(img.created_at);
        cards += `
  <figure class="card image-card">
    <div class="thumb"><img src="/uploads/${esc(img.file_name)}" alt="${esc(img.title)}" loading="lazy"></div>
    <figcaption>
      <div class="card-row">
        <h3 class="card-title">${esc(img.title)}</h3>
        <form method="post" action="/delete/${img.id}" onsubmit="return confirm('Delete \\'${esc(img.title).replace(/'/g, '\\u0027')}\\'? This cannot be undone.')">
          <button class="btn-text btn-text-danger" type="submit">Delete</button>
        </form>
      </div>
      <p class="meta">${esc(meta)}</p>
      <details class="rename">
        <summary>Rename</summary>
        <form class="rename-form" method="post" action="/edit/${img.id}">
          <input class="text-input" type="text" name="title" value="${esc(img.title)}" maxlength="255" required>
          <button class="btn btn-utility" type="submit">Save</button>
        </form>
      </details>
    </figcaption>
  </figure>`;
      }
    }

    const count = images.length;
    const badge = count === 0
      ? '<span class="badge-pill">Empty gallery</span>'
      : `<span class="badge-pill">${count} ${count === 1 ? 'image' : 'images'}</span>`;

    res.send(page('Your images — PicVault', `
${navBar(req.user)}
<main class="container">
  ${toast(req)}
  <header class="page-head">
    <div>
      <h2>Your images</h2>
      <p class="muted">Upload, rename and manage your pictures.</p>
    </div>
    ${badge}
  </header>
  <form class="card upload-card" method="post" action="/upload" enctype="multipart/form-data">
    <div class="upload-well">
      <span class="upload-icon">
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
          <polyline points="17 8 12 3 7 8"></polyline>
          <line x1="12" y1="3" x2="12" y2="15"></line>
        </svg>
      </span>
      <p class="upload-title">Drop an image here or click to browse</p>
      <p class="upload-hint" id="file-hint">JPG, PNG, GIF or WEBP · up to 10 MB</p>
      <input class="file-input" id="image-input" type="file" name="image" accept="image/*" required>
      <button class="btn btn-primary" type="submit">Upload image</button>
    </div>
  </form>
  <div class="image-grid">${cards}</div>
</main>
${footer()}
<script>
  const input = document.getElementById('image-input');
  const hint = document.getElementById('file-hint');
  input.addEventListener('change', function () {
    hint.textContent = input.files[0] ? input.files[0].name : 'JPG, PNG, GIF or WEBP · up to 10 MB';
  });
</script>`));
  } catch (err) {
    console.error(err);
    res.status(500).send(page('Error — PicVault', `<div class="error-card card"><h1>Something went wrong</h1><p class="muted">${esc(err.message)}</p><a href="/home">← Back to your images</a></div>`));
  }
});

// ---------- Upload ----------
app.post('/upload', requireAuth, uploadSingle('image'), async (req, res) => {
  if (!req.file) {
    return res.redirect('/home?err=' + encodeURIComponent('Please choose an image to upload.'));
  }
  try {
    const title = path.basename(req.file.originalname, path.extname(req.file.originalname)).slice(0, 255);
    await pool.query(
      'INSERT INTO images (user_id, file_name, title) VALUES (?, ?, ?)',
      [req.user.id, req.file.filename, title || 'Untitled']
    );
    res.redirect('/home?msg=' + encodeURIComponent('Image uploaded successfully.'));
  } catch (err) {
    console.error(err);
    res.redirect('/home?err=' + encodeURIComponent('Upload failed. Please try again.'));
  }
});

// ---------- Edit image title ----------
app.post('/edit/:id', requireAuth, async (req, res) => {
  const title = (req.body.title || '').trim();
  if (!title) {
    return res.redirect('/home?err=' + encodeURIComponent('Image name cannot be empty.'));
  }
  try {
    const [result] = await pool.query(
      'UPDATE images SET title = ? WHERE id = ? AND user_id = ?',
      [title.slice(0, 255), req.params.id, req.user.id]
    );
    if (result.affectedRows === 0) {
      return res.redirect('/home?err=' + encodeURIComponent('Image not found.'));
    }
    res.redirect('/home?msg=' + encodeURIComponent('Image renamed to "' + title + '".'));
  } catch (err) {
    console.error(err);
    res.redirect('/home?err=' + encodeURIComponent('Rename failed. Please try again.'));
  }
});

// ---------- Delete image ----------
app.post('/delete/:id', requireAuth, async (req, res) => {
  try {
    const [rows] = await pool.query(
      'SELECT file_name FROM images WHERE id = ? AND user_id = ?',
      [req.params.id, req.user.id]
    );
    if (!rows[0]) {
      return res.redirect('/home?err=' + encodeURIComponent('Image not found.'));
    }
    await pool.query('DELETE FROM images WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
    const filePath = path.join(UPLOADS_DIR, rows[0].file_name);
    if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
    res.redirect('/home?msg=' + encodeURIComponent('Image deleted.'));
  } catch (err) {
    console.error(err);
    res.redirect('/home?err=' + encodeURIComponent('Delete failed. Please try again.'));
  }
});

// ================= START =================
initDb()
  .then(() => {
    app.listen(PORT, () => console.log('PicVault running on port ' + PORT));
  })
  .catch(err => {
    console.error(err);
    process.exit(1);
  });
ls -la
# expect: package.json  application/
ls -R application
# expect: application/public/style.css  application/server.js

File 3 — application/public/style.css (PicVault light theme)

Link: nodejs-app/application/public/style.css

nano application/public/style.css

Paste the full stylesheet (Notion-inspired light theme, tokens from design.md). Full code:

/* ============================================================
   PicVault — global stylesheet (Notion-inspired light theme)
   Light theme only. Tokens from design.md.
   ============================================================ */

:root {
  /* ---- Colors ---- */
  --canvas:        #ffffff;
  --canvas-soft:   #f6f5f4;
  --hairline:      #e6e6e6;
  --field-border:  #dddddd;
  --primary:       #0075de;
  --primary-active:#005bab;
  --secondary:     #213183;
  --ink:           rgba(0, 0, 0, 0.95);
  --ink-secondary: #31302e;
  --ink-muted:     #615d59;
  --ink-faint:     #a39e98;
  --on-primary:    #ffffff;
  --danger:        #c2372d;

  /* Sticker palette — decorative only, never structural */
  --accent-sky:    #62aef0;
  --accent-purple: #d6b6f6;
  --accent-purple-deep: #391c57;
  --accent-pink:   #ff64c8;
  --accent-orange: #dd5b00;
  --accent-orange-deep: #793400;
  --accent-teal:   #2a9d99;
  --accent-green:  #1aae39;
  --accent-brown:  #523410;

  /* ---- Shape ---- */
  --r-xs: 4px;
  --r-sm: 5px;
  --r-md: 8px;
  --r-lg: 12px;
  --r-xl: 16px;
  --r-full: 9999px;

  /* ---- Elevation: barely-there layered shadows ---- */
  --shadow-1:
    0 0.175px 1.041px rgba(0, 0, 0, 0.01),
    0 0.8px 2.925px rgba(0, 0, 0, 0.02),
    0 2.025px 7.847px rgba(0, 0, 0, 0.027),
    0 4px 18px rgba(0, 0, 0, 0.04);
  --shadow-2:
    0 0.175px 1.041px rgba(0, 0, 0, 0.015),
    0 0.8px 2.925px rgba(0, 0, 0, 0.03),
    0 2.025px 7.847px rgba(0, 0, 0, 0.04),
    0 5px 14px rgba(0, 0, 0, 0.05),
    0 23px 52px rgba(0, 0, 0, 0.05);

  /* ---- Type ---- */
  --font: 'Inter', -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

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

body {
  font-family: var(--font);
  font-size: 16px;
  line-height: 1.5;
  color: var(--ink);
  background: var(--canvas-soft);
  font-feature-settings: 'lnum', 'locl';
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

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

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

/* ---------- Type scale ---------- */
h1, h2, h3, h4 { font-weight: 700; color: var(--ink); }
h1 { font-size: 40px; line-height: 1.1; letter-spacing: -1px; }
h2 { font-size: 26px; line-height: 1.23; letter-spacing: -0.625px; }
h3 { font-size: 22px; line-height: 1.27; letter-spacing: -0.25px; }

.card-title {
  font-size: 20px;
  font-weight: 600;
  line-height: 1.4;
  letter-spacing: -0.125px;
}

.eyebrow {
  font-size: 12px;
  font-weight: 600;
  line-height: 1.33;
  letter-spacing: 0.125px;
  text-transform: uppercase;
}

.muted  { color: var(--ink-muted); font-size: 15px; }
.caption { font-size: 14px; line-height: 1.43; color: var(--ink-muted); }
.footnote { font-size: 14px; line-height: 1.43; color: var(--ink-faint); }

/* ---------- Brand ---------- */
.brand {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
}
.brand:hover { text-decoration: none; }

.brand-mark {
  width: 24px;
  height: 24px;
  border-radius: 6px;
  background: var(--primary);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.brand-mark::after {
  content: '';
  width: 10px;
  height: 10px;
  border-radius: 3px;
  background: #ffffff;
  opacity: 0.95;
}

.brand-name {
  font-size: 18px;
  font-weight: 700;
  letter-spacing: -0.4px;
  color: var(--ink);
}

/* ---------- Buttons ---------- */
.btn {
  font-family: var(--font);
  font-size: 16px;
  font-weight: 500;
  line-height: 1.5;
  border: none;
  cursor: pointer;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: background 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease;
}

.btn-primary {
  background: var(--primary);
  color: var(--on-primary);
  border-radius: var(--r-full);
  padding: 12px 28px;
}
.btn-primary:hover { background: var(--primary-active); }
.btn-primary:active { transform: scale(0.97); }

.btn-secondary {
  background: var(--canvas);
  color: var(--ink);
  border-radius: var(--r-full);
  padding: 12px 28px;
  border: 1px solid var(--hairline);
  box-shadow: var(--shadow-1);
}
.btn-secondary:hover { background: #fbfaf9; }

.btn-utility {
  background: var(--canvas);
  color: var(--ink);
  border-radius: var(--r-md);
  padding: 4px 14px;
  border: 1px solid var(--hairline);
  font-size: 15px;
}
.btn-utility:hover { background: #fbfaf9; }

.btn-block { width: 100%; }

.btn-text {
  background: none;
  border: none;
  cursor: pointer;
  font-family: var(--font);
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-muted);
  padding: 4px 6px;
  border-radius: var(--r-sm);
}
.btn-text:hover { background: var(--canvas-soft); text-decoration: underline; }

.btn-text-danger { color: var(--danger); }
.btn-text-danger:hover { background: rgba(194, 55, 45, 0.06); }

/* ---------- Cards & containers ---------- */
.card {
  background: var(--canvas);
  border: 1px solid var(--hairline);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-1);
}

.container {
  width: 100%;
  max-width: 1080px;
  margin: 0 auto;
  padding: 0 24px;
}

/* ---------- Nav bar ---------- */
.nav-bar {
  position: sticky;
  top: 0;
  z-index: 10;
  background: var(--canvas);
  border-bottom: 1px solid var(--hairline);
}
.nav-inner {
  max-width: 1080px;
  margin: 0 auto;
  padding: 14px 24px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}
.nav-right { display: flex; align-items: center; gap: 18px; }
.nav-user { color: var(--ink-muted); }
.nav-user strong { color: var(--ink-secondary); font-weight: 600; }

/* ---------- Auth pages ---------- */
.auth-wrap {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 26px;
  padding: 48px 24px;
}
.auth-card {
  width: 100%;
  max-width: 420px;
  padding: 32px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.auth-card h1 { font-size: 26px; letter-spacing: -0.625px; }
.auth-card p { margin-top: -12px; }

.sticker-row { display: flex; gap: 8px; }
.sticker { width: 20px; height: 20px; border-radius: 5px; }

.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 14px; font-weight: 500; color: var(--ink-secondary); }

.text-input {
  font-family: var(--font);
  font-size: 15px;
  color: var(--ink);
  line-height: 1.33;
  border: 1px solid var(--field-border);
  border-radius: var(--r-xs);
  padding: 10px 12px;
  background: var(--canvas);
  width: 100%;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.text-input::placeholder { color: var(--ink-faint); }
.text-input:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: var(--shadow-1), 0 0 0 1px rgba(0, 117, 222, 0.25);
}

.auth-brand { align-self: center; }
.footer-note { color: var(--ink-faint); font-size: 13px; text-align: center; }

/* ---------- Alerts / toast ---------- */
.toast {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--canvas);
  border: 1px solid var(--hairline);
  border-radius: var(--r-lg);
  padding: 14px 18px;
  box-shadow: var(--shadow-2);
  font-size: 15px;
  color: var(--ink-secondary);
  margin: 24px 0 0;
}
.toast-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.toast-success .toast-dot { background: var(--accent-green); }
.toast-error .toast-dot { background: var(--danger); }
.toast-info .toast-dot { background: var(--primary); }

/* ---------- Home: page head ---------- */
.page-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  margin: 48px 0 24px;
}
.page-head .muted { margin-top: 6px; }

.badge-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: var(--canvas);
  color: var(--primary);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.125px;
  border-radius: var(--r-full);
  padding: 4px 12px;
  border: 1px solid var(--hairline);
  white-space: nowrap;
}

/* ---------- Upload card ---------- */
.upload-card { padding: 16px; }
.upload-well {
  border: 1.5px dashed #d8d4cf;
  border-radius: var(--r-lg);
  padding: 36px 24px;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  background: #fbfaf9;
}
.upload-icon {
  width: 44px;
  height: 44px;
  border-radius: 12px;
  background: var(--secondary);
  color: #ffffff;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 4px;
}
.upload-title {
  font-size: 17px;
  font-weight: 600;
  color: var(--ink);
  letter-spacing: -0.2px;
}
.upload-hint { font-size: 14px; color: var(--ink-faint); }
.upload-well .btn { margin-top: 10px; }

.file-input {
  font-size: 14px;
  color: var(--ink-muted);
  max-width: 100%;
}
.file-input::file-selector-button {
  font-family: var(--font);
  font-size: 15px;
  font-weight: 500;
  color: var(--ink);
  background: var(--canvas);
  border: 1px solid var(--hairline);
  border-radius: var(--r-md);
  padding: 8px 14px;
  margin-right: 12px;
  cursor: pointer;
}
.file-input::file-selector-button:hover { background: #fbfaf9; }

/* ---------- Image grid ---------- */
.image-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 20px;
  margin: 24px 0 64px;
}
.image-card {
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
.thumb {
  background: var(--canvas-soft);
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border-bottom: 1px solid var(--hairline);
}
.thumb img { width: 100%; height: 100%; object-fit: cover; }
.image-card figcaption {
  padding: 16px 18px 18px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.card-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 8px;
}
.card-title {
  font-size: 16px;
  font-weight: 600;
  letter-spacing: -0.125px;
  overflow-wrap: anywhere;
}
.meta { font-size: 13px; color: var(--ink-faint); }

/* ---------- Rename ---------- */
.rename { margin-top: 6px; }
.rename summary {
  list-style: none;
  cursor: pointer;
  font-size: 13px;
  font-weight: 500;
  color: var(--ink-muted);
  padding: 2px 0;
  width: fit-content;
}
.rename summary::-webkit-details-marker { display: none; }
.rename summary:hover { color: var(--primary); }
.rename-form { display: flex; gap: 8px; margin-top: 8px; }
.rename-form .text-input { padding: 8px 10px; flex: 1; font-size: 14px; }

/* ---------- Empty state ---------- */
.empty-state {
  text-align: center;
  padding: 64px 24px;
  margin-bottom: 64px;
}
.empty-tile {
  width: 64px;
  height: 64px;
  border-radius: var(--r-xl);
  margin: 0 auto 18px;
  background: var(--accent-purple);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
}
.empty-tile::after {
  content: '';
  width: 26px;
  height: 26px;
  border-radius: 6px;
  background: var(--accent-orange);
  opacity: 0.9;
}
.empty-state h3 { margin-bottom: 8px; }
.empty-state p { max-width: 340px; margin: 0 auto 22px; }

/* ---------- Footer ---------- */
.footer {
  margin-top: auto;
  background: var(--canvas-soft);
  border-top: 1px solid var(--hairline);
  padding: 28px 24px;
  text-align: center;
  color: var(--ink-faint);
  font-size: 13px;
}

/* ---------- Error page ---------- */
.error-card {
  max-width: 480px;
  margin: 80px auto;
  padding: 32px;
  text-align: center;
  display: flex;
  flex-direction: column;
  gap: 16px;
  align-items: center;
}

/* ---------- Responsive ---------- */
@media (max-width: 600px) {
  .auth-card { padding: 24px; }
  .page-head { flex-direction: column; align-items: flex-start; }
  .nav-user { display: none; }
  .upload-well { padding: 28px 16px; }
  .btn-primary, .btn-secondary { padding: 12px 22px; }
}

B3. Create Dockerfile piece by piece

Link (reference): nodejs-app/Dockerfile The reference Dockerfile uses npm ci with package-lock.json; this manual path uses npm install so you do not need a lock file.

nano Dockerfile

Pieces

FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY application/ ./application/
RUN mkdir -p /app/uploads
EXPOSE 3000
CMD ["node", "application/server.js"]

Full Dockerfile

FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY application/ ./application/
RUN mkdir -p /app/uploads
EXPOSE 3000
CMD ["node", "application/server.js"]

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

Stay in ~/assignment-docker-node. Create these before pull / run / compose up.

Reference copies:

B4.1 — .env

cd ~/assignment-docker-node
nano .env
# MySQL root (SQLyog first setup)
MYSQL_ROOT_PASSWORD=rootpass

# App → MySQL (hostname "db" = service name on the Docker network)
DB_HOST=db
DB_NAME=userdb
DB_USER=appuser
DB_PASS=apppass

# Port Node listens on inside the container
PORT=3000

# Secret used to sign login cookies (keep it secret)
SESSION_SECRET=change-me-to-a-long-random-string

# Host ports (browser + SQLyog on Windows)
APP_PORT=3000
DB_PORT=3307

Do not commit .env to a public repo if you ever change passwords. Keep a safe template as .env.example instead.

B4.2 — .env.example

nano .env.example

Same contents as .env is fine for this assignment (or copy from nodejs-app/.env.example).

B4.3 — .dockerignore

nano .dockerignore
.env
.git
uploads
node_modules
*.md

B4.4 — docker-compose.yml

nano docker-compose.yml

Paste the full file:

# Docker Compose — Node.js app + MySQL
# Values come from .env (created above)
# database.sql is mounted into MySQL's init dir → DB/user/tables are
# created automatically the first time a fresh MySQL volume starts

services:
  app:
    build:
      context: .
      # Helps npm install on some WSL setups
      network: host
    image: assignment-docker-node
    container_name: assignment-docker-app
    ports:
      - "${APP_PORT:-3000}:3000"
    env_file:
      - .env
    environment:
      DB_HOST: ${DB_HOST:-db}
      DB_NAME: ${DB_NAME:-userdb}
      DB_USER: ${DB_USER:-appuser}
      DB_PASS: ${DB_PASS:-apppass}
      PORT: ${PORT:-3000}
    volumes:
      - assignment-docker-uploads:/app/uploads
    depends_on:
      - db
    networks:
      - assignment-docker-net

  db:
    image: mysql:8.0
    container_name: assignment-docker-db
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpass}
    volumes:
      - assignment-docker-mysql:/var/lib/mysql
      # Auto-create database + tables on a fresh volume (runs once)
      - ./database.sql:/docker-entrypoint-initdb.d/01-database.sql:ro
    ports:
      - "${DB_PORT:-3307}:3306"
    networks:
      assignment-docker-net:
        aliases:
          - db

networks:
  assignment-docker-net:
    name: assignment-docker-net
    driver: bridge

volumes:
  assignment-docker-mysql:
    name: assignment-docker-mysql
  assignment-docker-uploads:
    name: assignment-docker-uploads

B4.5 — Check files, then choose how to run

ls -la
# expect: .env  .env.example  .dockerignore  docker-compose.yml  Dockerfile  database.sql  package.json  application/
PathSections
Manual CLIB5–B8 (pull / network / volumes / build / run)
Compose shortcutB9 (docker compose up --build -d)

Create B4 files first — before any containers start.


B5. Pull → network → volumes

docker pull mysql:8.0
docker pull node:22-alpine
docker images

docker network create assignment-docker-net
# skip create if it already exists

docker volume create assignment-docker-mysql
docker volume create assignment-docker-uploads
docker volume ls

If old PHP containers use the same host ports:

docker rm -f docker-task-app docker-task-db
# or: cd ~/docker-task-php && docker compose down

Skip network/volume create if you only use Compose (B9).


B6. Build

cd ~/assignment-docker-node
docker build -t assignment-docker-node .
docker images

If you change any Node file later: edit the file → run docker build -t assignment-docker-node . again → recreate the app container.

Skip a separate build if you use docker compose up --build (B9).


B7. Run MySQL (host port 3307)

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

# wait for "ready for connections", then Ctrl+C

Create the database + tables (one of these):

  • SQLyog / mysql CLI (any time): run nodejs-app/database.sql on port 3307 — creates userdb, the appuser, and the users + images tables. Same tables as Path A.
  • Compose (B9) only: a plain docker run does not run database.sql automatically. Compose mounts it into MySQL's init directory, so it runs once on a fresh volume.

B8. Run Node app

docker run -d \
  --name assignment-docker-app \
  --network assignment-docker-net \
  -e DB_HOST=db \
  -e DB_NAME=userdb \
  -e DB_USER=appuser \
  -e DB_PASS=apppass \
  -e PORT=3000 \
  -e SESSION_SECRET=change-me-to-a-long-random-string \
  -v assignment-docker-uploads:/app/uploads \
  -p 3000:3000 \
  assignment-docker-node
docker logs -f assignment-docker-app

Browser: http://localhost:3000 — register an account, then upload images.

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

B9. Optional — start everything with Compose

docker rm -f assignment-docker-app assignment-docker-db   # if manual containers exist
cd ~/assignment-docker-node
docker compose config
docker compose up --build -d
docker compose ps
docker compose logs -f db
# wait for MySQL ready, then Ctrl+C

On a fresh MySQL volume, Compose runs database.sql automatically (the init mount in B4.4). If the DB volume already exists (or you started MySQL manually in B7), run the SQL yourself in SQLyog on port 3307.

Browser: http://localhost:3000 (or your APP_PORT).

docker compose down       # stop containers; keep named volumes
docker compose down -v    # stop and DELETE named volumes (wipes DB/uploads)

B10. Persistence + cleanup

Same idea as PHP: remove containers, start again with the same volume names → data remains.

docker rm -f assignment-docker-app assignment-docker-db
# or: docker compose down

docker volume rm assignment-docker-mysql assignment-docker-uploads
docker network rm assignment-docker-net
docker rmi assignment-docker-node

# full Compose wipe:
# docker compose down -v

Linked source files (quick index)

Node — create these yourself

You createOpen / copy from
package.jsonnodejs-app/package.json
application/server.jsnodejs-app/application/server.js
application/public/style.cssnodejs-app/application/public/style.css
Dockerfilenodejs-app/Dockerfile (manual version above uses npm install; reference uses npm ci + package-lock.json)
.env / .env.examplenodejs-app/.env.example
docker-compose.ymlnodejs-app/docker-compose.yml
.dockerignorenodejs-app/.dockerignore

Database SQL

TaskFile
Create DB / user / table (SQLyog)SETUP_DATABASE.md · setup.sql
Node init script (Compose auto-runs on a fresh MySQL volume)nodejs-app/database.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.