PHP + Apache
php:<version>-apache is one of the most-used images on Docker Hub.
Apache is pre-installed and configured to serve /var/www/html/,
with mod_php enabled for .php files.
Build a tiny PHP app:
mkdir -p ~/php-app/src && cd ~/php-app
cat > src/index.php <<'PHP'
<?php
header('Content-Type: text/plain');
echo "PHP " . PHP_VERSION . " running in a container\n";
echo "Hostname: " . gethostname() . "\n";
echo "Loaded extensions: " . count(get_loaded_extensions()) . "\n";
PHP
cat > Dockerfile <<'EOF'
FROM php:8-apache
COPY src/ /var/www/html/
EXPOSE 80
EOF
Build + run:
docker build -t cac-php-app .
docker run -d --name php-app -p 8090:80 cac-php-app
curl -s http://localhost:8090/
You should see the PHP version, the container's hostname, and a
loaded-extensions count. That's a full PHP request lifecycle running
on a containerized stack you built in seconds.
Click Verify step.
Hint
FROM php:8-apache; COPY your src/ into /var/www/html/.
Node.js with environment variables
Configuration belongs in environment variables, not in the image
or in source. Docker's -e KEY=VALUE at runtime injects them.
Build a minimal Node service:
mkdir -p ~/node-app && cd ~/node-app
cat > package.json <<'JSON'
{
"name": "cac-node-app",
"version": "1.0.0",
"main": "server.js"
}
JSON
cat > server.js <<'JS'
const http = require('http');
const env = process.env.NODE_ENV || 'development';
const port = Number(process.env.PORT || 3000);
http.createServer((_, res) => {
res.setHeader('Content-Type', 'text/plain');
res.end(`Node ${process.version}\nNODE_ENV=${env}\nPORT=${port}\n`);
}).listen(port, () => console.log(`listening on ${port} as ${env}`));
JS
cat > Dockerfile <<'EOF'
FROM node:lts-alpine
WORKDIR /src/app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
EOF
Build + run with NODE_ENV=production:
docker build -t cac-node-app .
docker run -d --name node-app \
-e NODE_ENV=production \
-p 3000:3000 \
cac-node-app
curl -s http://localhost:3000/
You should see NODE_ENV=production in the response. To run the
same image as a dev instance, just pass a different -e:
docker run --rm -e NODE_ENV=development -p 3001:3000 cac-node-app &
sleep 1 && curl -s http://localhost:3001/
Same image, different behaviour โ that's the dependency-injection
pattern Docker is built around.
Click Verify step once :3000 reports NODE_ENV=production.
Hint
FROM node:lts-alpine; install deps in the image; pass `-e NODE_ENV=production` at runtime.
A multi-port service
Some images expose multiple ports โ Jenkins listens on 8080 for
HTTP and 50000 for its agent protocol; databases often have
admin + client ports. Each -p publishes one host:container port
pair.
For this lab the publishable target is httpd (lightweight, two
listeners on demand). Jenkins LTS is the real-world reference but
pulls ~600MB; we'll stand up a smaller proxy that demonstrates the
multi-port pattern.
docker run -d --name multi-svc \
-p 8100:80 \
-p 8101:80 \
httpd:2.4
Both :8100 and :8101 reach the same container's port 80:
curl -sI http://localhost:8100/ | head -1
curl -sI http://localhost:8101/ | head -1
In a real Jenkins setup the same pattern publishes two different
listeners:
# (Reference โ don't run for this lab, ~600MB pull)
# docker run -d --name jenkins \
# -p 8080:8080 \
# -p 50000:50000 \
# jenkins/jenkins:lts
Inspect the published ports:
docker port multi-svc
docker inspect --format '{{.NetworkSettings.Ports}}' multi-svc
Click Verify step when both :8100 and :8101 answer.
Hint
Repeat `-p` to publish multiple ports (e.g. -p 8080:8080 -p 5000:5000).
Reading logs + restart policies
Two operational primitives you'll touch every day in production.
Logs. Anything an app writes to stdout/stderr is captured by the
container runtime. View it with docker logs:
docker logs php-app
docker logs --tail 20 php-app # last 20 lines
docker logs -f php-app & # follow (Ctrl-C to stop)
curl -s http://localhost:8090/ >/dev/null # generate a request
curl -s http://localhost:8090/ >/dev/null
sleep 1; kill %1 2>/dev/null
Apache writes a line per request. Same idea applies to nginx, Node,
Python โ anything that logs to stdout is captured.
Restart policies. Containers don't restart by default. Crash =
gone. Four policies, set with --restart:
| Policy | When does it restart? |
|--------------------|------------------------|
| no (default) | never |
| on-failure | only on non-zero exit |
| unless-stopped | always, unless you docker stop it explicitly |
| always | always โ even if explicitly stopped (resumes after daemon restart) |
Start a deliberately-crashing container with unless-stopped and
watch it come back:
docker run -d --name flaky --restart=unless-stopped alpine sh -c "sleep 3; exit 1"
sleep 4
docker inspect -f '{{.RestartCount}}' flaky
sleep 4
docker inspect -f '{{.RestartCount}}' flaky
RestartCount keeps climbing โ Docker keeps reviving it. To stop
the loop:
docker stop flaky
docker rm flaky
Click Verify step when the php-app logs show at least one
curl request from this scenario.
Hint
`docker logs <name>`; pass `--restart unless-stopped` to keep a service alive.