Organize the answer around ownership, limits, failure, and recovery. Definitions become interview-ready when they survive a concrete production scenario.
Question set
35 detailed answers
01What is Docker and what problem does it solve?
junior
Short answer: Docker is a containerization platform that packages an application together with all its dependencies, libraries, and configuration into an isolated, portable image. It solves the "works on my machine, but not on the server" problem.
In depth:
The classic pain: a developer wrote code on their laptop (Python 3.11, a specific version of libpq, environment variables), but the server has a different OS version, doesn't have the other libraries, and the variables aren't set — the application crashes. Docker pins the whole environment into an image, and that image runs identically locally, in CI, and in production.
What Docker gives you:
- Reproducibility — the same image everywhere. The environment is described as code (a Dockerfile) and can be versioned in git.
- Isolation — containers don't interfere with each other (dependencies, ports, processes).
- Portability — the image runs on any machine with Docker, regardless of the host OS.
- Fast startup — a container comes up in seconds (unlike a VM).
# Build an image from the Dockerfile in the current directory
docker build -t myapp:1.0 .
# Run a container from the image, publish a port, in the background
docker run -d -p 8000:8000 --name myapp myapp:1.0
# List running containers
docker ps
# Get a shell inside a running container
docker exec -it myapp bash
# Container logs
docker logs -f myapp
⚠️ Gotcha: Docker doesn't "virtualize hardware" and doesn't run a full OS. Containers use the host's kernel. So a Linux image won't run natively on the Windows kernel — on Windows/macOS a lightweight Linux VM runs under the hood (via WSL2 or a hypervisor), and the containers run inside it.
02Container vs virtual machine
junior
Short answer: A VM virtualizes hardware and drags along a full guest OS with its own kernel. A container virtualizes the OS and shares the host's kernel, isolating only at the process level. A container is lighter, faster, and smaller.
In depth:
Virtual machine: Container:
┌───────────────────────┐ ┌───────────────────────┐
│ App A │ App B │ │ App A │ App B │
│ Bins/Libs│ Bins/Libs │ │ Bins/Libs│ Bins/Libs │
│ Guest OS │ Guest OS │ ├──────────┴─────────────┤
│ (kernel) │ (kernel) │ │ Docker Engine │
├───────────┴────────────┤ ├────────────────────────┤
│ Hypervisor │ │ Host OS (shared kernel) │
├────────────────────────┤ ├────────────────────────┤
│ Host OS / hardware │ │ Hardware │
└────────────────────────┘ └────────────────────────┘
| Criterion | VM | Container |
|---|---|---|
| Kernel | own, guest | shared with host |
| Size | gigabytes | megabytes |
| Startup | minutes | seconds |
| Isolation | strong (at the hardware level) | weaker (at the process/kernel level) |
| Overhead | high | low |
Container isolation is built on Linux kernel mechanisms:
- namespaces — isolating "what the container sees": processes (PID), network, mount points, users, hostname.
- cgroups (control groups) — limiting "how much the container can consume": CPU, memory, IO.
# Limit a container's resources via cgroups
docker run --memory=512m --cpus=1.5 myapp:1.0
⚠️ Gotcha: "weaker isolation" really matters for security. A vulnerability in the kernel can affect all containers on the host (escape). That's why you don't run trusted and untrusted code in containers on the same kernel without extra measures (gVisor, separate VMs). And root inside a container is (by default) root on the host when mounting, hence the rule not to run processes as root.
03Image vs container
junior
Short answer: An image is an immutable template, a "snapshot" of a filesystem with an application. A container is a running instance of an image — a process with a thin writable layer on top of the image. Analogy: the image is the class, the container is the object.
In depth:
- Image is read-only, made of layers. One image — many containers.
- Container is the image + a thin writable layer on top. All filesystem changes during runtime are written to this layer. When the container is removed, the layer disappears (unless the data is moved out to a volume).
docker images # list images
docker ps -a # all containers (including stopped ones)
docker run myapp # create and run a container
docker stop <id> # stop
docker start <id> # start again (same container, same writable layer)
docker rm <id> # remove the container (the writable layer is deleted)
docker rmi myapp # remove the image
⚠️ Gotcha: data written inside the container (not into a volume) disappears on docker rm. A common junior mistake is to put a database in the container's filesystem and be surprised that everything is gone after recreating it. Containers are ephemeral.
04Image layers and caching
middle
Short answer: An image is built from layers — each instruction in the Dockerfile (FROM, RUN, COPY...) creates a new layer. Docker caches layers: on rebuild, unchanged layers are taken from the cache. That's why the order of instructions is critical — put rarely changing layers above frequently changing ones.
In depth:
A layer's cache is invalidated as soon as the instruction or its input changes (for example, a file that's copied via COPY). All subsequent layers are rebuilt too.
Bad — any code change breaks the dependency-install cache:
FROM python:3.12-slim
WORKDIR /app
COPY . . # code changes often → layer invalidated
RUN pip install -r requirements.txt # dependencies reinstalled every time 😱
Good — dependencies first, then code:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt . # changes rarely
RUN pip install -r requirements.txt # cached until requirements.txt changes ✅
COPY . . # code changes often — but this is the last layer
The same trick for Node:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
⚠️ Gotcha: combine related commands into a single RUN with &&, otherwise apt-get update in one layer and apt-get install in another can drift apart (a cached update + a fresh install → stale package indexes). And every extra layer increases the image size.
05Main Dockerfile instructions
junior
Short answer: FROM — base image, WORKDIR — working directory, COPY/ADD — copying files, RUN — execute a command at build time, ENV — environment variables, ARG — build arguments, EXPOSE — document a port, CMD/ENTRYPOINT — the container's startup command.
In depth:
# Base image (required, usually the first instruction)
FROM python:3.12-slim
# Metadata
LABEL maintainer="team@example.com"
# Build argument (available only at build time)
ARG APP_VERSION=1.0.0
# Environment variable (available both at build time and in the container's runtime)
ENV PYTHONUNBUFFERED=1 \
APP_HOME=/app
# Working directory (created if missing; all following commands are relative to it)
WORKDIR $APP_HOME
# Copy files from the host into the image
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create an unprivileged user and switch to it
RUN useradd --create-home appuser
USER appuser
# Document the port (does NOT publish the port by itself!)
EXPOSE 8000
# Startup command
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]
Best practices:
- A specific base image tag (
python:3.12-slim, notpython:latest). - A
.dockerignoreso you don't drag in.git,node_modules,__pycache__. - Minimize layers, combine
RUN. - Don't run as root (
USER). - Copy and install dependency manifests in a separate layer before copying application source. Then a source-only change reuses the cached dependency layer instead of running an expensive install again.
⚠️ Gotcha: EXPOSE 8000 doesn't publish anything to the outside — it's only documentation/metadata. To make the port reachable from the host, you need docker run -p 8000:8000. Also, use WORKDIR instead of RUN cd /app — cd only takes effect within a single RUN.
06COPY vs ADD
middle
Short answer: COPY simply copies files/directories from the host into the image. ADD can do the same plus extract local tar archives and download files by URL. Recommendation: always use COPY, and use ADD only for extracting tarballs.
In depth:
COPY ./src /app/src # plain copy — predictable
ADD app.tar.gz /app/ # automatically extracts the archive into /app/
ADD https://example.com/f.txt / # downloads by URL (but curl/wget in RUN is better)
ADD with a URL is an antipattern: it doesn't cache properly, doesn't verify checksums, and bloats the layer. For downloading it's better to:
RUN curl -fsSL https://example.com/f.txt -o /app/f.txt
⚠️ Gotcha: ADD with a tar archive "magically" extracts it, which can surprise a reader of the Dockerfile. By the principle of least surprise, use an explicit COPY, and do extraction as a separate command if it isn't needed implicitly.
07CMD vs ENTRYPOINT
middle
Short answer: ENTRYPOINT defines the executable/command itself that the container "always runs". CMD defines the default arguments (or the whole command), which are easy to override in docker run. They're often used together: ENTRYPOINT — the program, CMD — the arguments.
In depth:
If only CMD is set, it can be fully replaced in docker run:
CMD ["python", "app.py"]
docker run myapp # → python app.py
docker run myapp python other.py # → python other.py (CMD replaced entirely)
If ENTRYPOINT is set, the arguments from docker run are appended to it:
ENTRYPOINT ["python"]
CMD ["app.py"]
docker run myapp # → python app.py
docker run myapp other.py # → python other.py (only the CMD part is replaced)
Exec form vs shell form (important!):
CMD ["python", "app.py"] # exec form: PID 1 = python, signals reach it ✅
CMD python app.py # shell form: PID 1 = /bin/sh -c, python is a child ⚠️
The exec form (a JSON array) is preferred because the application becomes PID 1 and correctly receives signals (SIGTERM on docker stop). In the shell form, the shell receives the signals, and the application may not stop gracefully.
⚠️ Gotcha: in the shell form the application won't receive SIGTERM, and docker stop will wait 10 seconds and then kill it with SIGKILL (data loss, no graceful shutdown). Always prefer the exec form.
08ARG vs ENV
middle
Short answer: ARG is a build-time variable, available only in the Dockerfile during docker build and not persisted in the image. ENV is an environment variable, available both at build time and at runtime inside the container, and it's persisted in the image.
In depth:
ARG NODE_VERSION=20 # default value, overridden at build time
FROM node:${NODE_VERSION}
ARG BUILD_ENV # no value → passed via --build-arg
ENV APP_ENV=production # stays in the image and is visible to the process
docker build --build-arg BUILD_ENV=staging -t myapp .
docker run -e APP_ENV=dev myapp # ENV can be overridden at run time
⚠️ Gotcha: don't pass secrets via ARG — even if the value is never stored in ENV, it can remain in the layer history and in docker history. For secrets at build time use --secret (BuildKit), and at runtime use environment variables via -e/--env-file or a secrets manager.
09Multi-stage builds
middle
Short answer: A multi-stage build is several FROM stages in a single Dockerfile, where only the build result is copied into the final image, while heavy build tools (compilers, dev dependencies) stay in the intermediate stages. The main goal is to dramatically reduce the size and attack surface of the resulting image.
In depth:
Without multi-stage, the image of a Go application drags along the entire Go compiler (~300+ MB). With multi-stage — only the binary (a few MB).
# --- Build stage ---
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
# --- Final stage ---
FROM alpine:3.20
COPY --from=builder /app/server /usr/local/bin/server
ENTRYPOINT ["server"]
For a frontend (build the static assets, serve via nginx):
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
⚠️ Gotcha: only what you explicitly copy via COPY --from=... ends up in the final image. If you forget to copy a runtime dependency (for example, the CA certificates needed for HTTPS requests in scratch/alpine) — the application will crash in production, not during the build.
10How to reduce image size
middle
Short answer: Use lightweight base images (alpine, slim, distroless), multi-stage builds, a .dockerignore, combine RUNs and clean the package manager cache in the same layer, and don't install dev dependencies in production.
In depth:
FROM python:3.12-slim # slim instead of the full one (~120 MB vs ~1 GB)
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc \
&& pip install --no-cache-dir -r requirements.txt \
&& apt-get purge -y gcc \
&& rm -rf /var/lib/apt/lists/* # clean up in the SAME layer, otherwise the weight stays
.dockerignore — don't drag unnecessary things into the build context:
.git
node_modules
__pycache__
*.log
.env
dist
.venv
Comparison of bases:
alpine— ~5 MB, musl libc (sometimes incompatible with libraries built against glibc).slim— a stripped-down Debian, a good balance of compatibility and size.distroless— only the runtime, no shell and no package manager (maximum security).
⚠️ Gotcha: deleting files in a later layer doesn't reduce the image — the preceding layer has already committed their weight. You must clean up in the same RUN. Also, alpine uses musl instead of glibc: binary Python wheels (manylinux) may not work, and you'll have to compile → which can turn out slower and more painful than slim.
11Volumes vs bind mounts
middle
Short answer: Both are ways to persist data outside the container's ephemeral layer. A volume is managed by Docker and stored in its area (/var/lib/docker/volumes) — for production data (a DB). A bind mount mounts a specific folder from the host into the container — handy for development (live code).
In depth:
# Named volume — Docker manages the storage itself
docker volume create pgdata
docker run -v pgdata:/var/lib/postgresql/data postgres:16
# Bind mount — a specific host path → a path in the container
docker run -v $(pwd)/src:/app/src myapp # host code is visible inside (hot reload)
# tmpfs — in RAM, not written to disk (secrets, temporary data)
docker run --tmpfs /tmp myapp
In compose:
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data # named volume
web:
build: .
volumes:
- ./src:/app/src # bind mount for development
volumes:
pgdata:
| Volume | Bind mount | |
|---|---|---|
| Management | Docker | manual (host path) |
| Portability | high | tied to the host's structure |
| Use case | production, DB data | development, configs |
⚠️ Gotcha: a bind mount "covers" the contents of the target folder in the container. If you mount an empty host folder onto /app/node_modules, the modules installed in the image disappear. The fix is an anonymous volume for node_modules on top of the code bind mount. Also, a bind mount ties you to host paths and can break portability and permissions (UID/GID).
12Docker networking: bridge, host, ports
middle
Short answer: By default, containers are attached to the bridge network — each has its own internal IP, and ports are published to the outside via -p host:container. On a user-defined bridge network, containers see each other by service name (built-in DNS). The host network removes isolation — the container uses the host's network directly.
In depth:
# Port publishing: host:8080 → container:80
docker run -p 8080:80 nginx
# User-defined network → containers resolve each other by name
docker network create appnet
docker run -d --name db --network appnet postgres:16
docker run -d --name api --network appnet myapi # inside api: host "db" resolves to db's IP
# host network (Linux only): no network isolation, no -p
docker run --network host nginx
Driver types:
- bridge (default) — an isolated virtual network, NAT to the outside.
- host — shares the network with the host, no port publishing, higher performance, lower isolation.
- none — no network.
- overlay — for multiple hosts (Swarm/k8s).
⚠️ Gotcha: on the default bridge network, DNS by container names doesn't work — name resolution exists only on a user-defined network (docker network create). That's why compose automatically creates its own network and services see each other by name. Also: inside a container, localhost is the container itself, not the host; to reach the host on Mac/Windows you use host.docker.internal.
13What is docker-compose?
junior
Short answer: docker-compose is a tool for describing and running multi-container applications from a single YAML file. Instead of long docker run commands for each service, you describe all services, networks, volumes, and dependencies declaratively, and bring everything up with one command, docker compose up.
In depth:
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/app
depends_on:
db:
condition: service_healthy
networks:
- appnet
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
retries: 5
networks:
- appnet
volumes:
pgdata:
networks:
appnet:
docker compose up -d # bring everything up in the background
docker compose ps # service status
docker compose logs -f web # logs of a service
docker compose down # stop and remove (volumes are kept)
docker compose down -v # + remove volumes
⚠️ Gotcha: depends_on without condition: service_healthy only guarantees startup order, not the service's readiness. The DB container is "started", but Postgres inside is still initializing — the application will crash when connecting. You need healthchecks or retry logic in the application. Compose is great for local development and small environments; for production orchestration with scaling you reach for Kubernetes.
14Environment variables and secrets in Docker
middle
Short answer: Configuration is passed via environment variables (-e, --env-file, environment in compose). Secrets (passwords, tokens) must not be baked into the image — use env at run time, Docker secrets, or external managers (Vault, AWS Secrets Manager, k8s Secrets).
In depth:
# One at a time
docker run -e DATABASE_URL=postgres://... myapp
# From a file
docker run --env-file .env myapp
# compose: reference to a .env file, values not in the repository
services:
web:
env_file: .env
environment:
LOG_LEVEL: info
Docker secrets (Swarm) mount the secret as a file in /run/secrets/, not into env:
services:
web:
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txt
⚠️ Gotcha: a secret passed via ENV in a Dockerfile or --build-arg remains in the image layers and is visible in docker history / docker inspect — anyone with access to the image can read it. Pass secrets only at run time, and add .env to .gitignore and .dockerignore. Environment variables are visible to processes and in /proc/<pid>/environ, so for highly sensitive data, file-based secrets or an external manager are better.
16Stateless containers and one process per container
middle
Short answer: A container should be stateless — it shouldn't hold important state inside itself (it can be killed and recreated at any moment). State goes into a database, cache, volume, or object storage. There's also the principle of "one process (one responsibility) per container" — a container runs a single service, not a bundle of daemons.
In depth:
Why stateless:
- Containers are ephemeral; the orchestrator can kill and recreate them at any time (scaling, updates, a node failure).
- Stateless containers scale horizontally with ease — just spin up 5 more copies behind a load balancer.
- State → external services: PostgreSQL, Redis, S3, named volumes.
Why one process:
- Transparent logs (everything goes to stdout/stderr → collected centrally).
- Correct signal handling and lifecycle.
- Isolation and independent scaling (web, worker, DB — separate containers).
# Different responsibilities → different containers
services:
web: { build: ., command: gunicorn app:app }
worker: { build: ., command: celery -A app worker }
redis: { image: redis:7 }
⚠️ Gotcha: "one process" doesn't literally mean one PID — Gunicorn with workers is fine (one process manager with one responsibility). The anti-pattern is cramming nginx + app + cron + sshd into one container via supervisord. Also, don't write logs to files inside the container — write to stdout/stderr so the log-collection system picks them up.
17What are CI and CD?
junior
Short answer: CI (Continuous Integration) is frequent, automatic integration of changes into a shared branch with an automated build and test run. CD has two meanings: Continuous Delivery — we automatically prepare a release but ship it on manual approval; Continuous Deployment — we automatically ship to production with no manual step.
In depth:
- CI — every push/PR triggers a pipeline: lint, tests, build. The goal is to catch problems early and never let the main branch go "broken." Small frequent merges instead of "one big merge once a month."
- Continuous Delivery — after CI the artifact is always ready to deploy, but the production rollout requires a button press (approve). Suited to cases where you need control over the timing of a release.
- Continuous Deployment — every change that passes the checks goes to production automatically. Requires high maturity of tests, monitoring, and fast rollbacks.
Continuous Delivery: commit → CI → build → staging → [manual approve] → prod
Continuous Deployment: commit → CI → build → staging → prod (automatically)
⚠️ Gotcha: the two "CD"s are often confused. In an interview, point out that the acronym is ambiguous and explain the difference: the key distinction is whether there's a manual step before production. Delivery = "ready to ship at any moment," Deployment = "ships itself."
18Pipeline stages: lint → test → build → deploy
junior
Short answer: A typical pipeline: lint (static analysis/style) → test (unit/integration tests) → build (build an artifact/Docker image) → deploy (roll out). Each subsequent stage runs only if the previous one passed — fail fast.
In depth:
- Lint / static analysis — the cheapest and fastest stage; catches style errors and some bugs before the code even runs (
ruff,eslint,gofmt, type checking withmypy/tsc). - Test — unit tests, then integration tests. Often with coverage measurement. A test failure blocks the merge.
- Build — build the artifact: Docker image, jar, binary, frontend bundle. Tag by version/commit.
- Deploy — publish the image to the registry and roll out to an environment (staging → prod). Often with DB migrations and smoke tests afterward.
The order is deliberate: cheap, fast checks run first so that obviously broken code is filtered out quickly, without wasting time on an expensive build.
⚠️ Gotcha: DB migrations are a separate pain point. They need to be compatible with the previous version of the code (expand/contract), otherwise during a rolling deploy the old pods will crash on the new schema. Don't "drop a column and deploy the code at the same time."
19GitHub Actions / GitLab CI in brief
middle
Short answer: Both are CI/CD systems that describe the pipeline as YAML inside the repo. In GitHub Actions: a workflow contains jobs, a job contains steps, and jobs run on runners. In GitLab CI: a .gitlab-ci.yml file with stages and jobs.
In depth:
GitHub Actions (.github/workflows/ci.yml):
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- run: ruff check .
- run: pytest
build:
needs: test # runs only after test succeeds
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t myapp:${{ github.sha }} .
GitLab CI (.gitlab-ci.yml):
stages: [test, build, deploy]
test:
stage: test
image: python:3.12
script:
- pip install -r requirements.txt
- pytest
build:
stage: build
script:
- docker build -t registry.example.com/myapp:$CI_COMMIT_SHA .
- docker push registry.example.com/myapp:$CI_COMMIT_SHA
⚠️ Gotcha: by default, jobs within a single stage run in parallel, and each job starts in a clean environment — artifacts aren't passed between jobs automatically. You have to declare dependencies explicitly (needs) and pass through artifacts/cache. Store secrets in protected variables/secrets, not in the YAML.
20Deployment strategies: rolling, blue-green, canary
middle
Short answer: Rolling — gradually replace instances with the new version one by one. Blue-green — bring up a full new environment (green) alongside the old one (blue) and switch all traffic over at once. Canary — route the new version to a small fraction of traffic, observe, and gradually increase it.
In depth:
| Strategy | How | Pros | Cons |
|---|---|---|---|
| Rolling | replace pods incrementally | no downtime, no double resources | two versions run at once, slow rollback |
| Blue-green | two full environments, switch traffic | instant rollback (switch back), no version mixing | needs 2x resources |
| Canary | 5% → 25% → 100% of traffic to the new version | early detection of problems on a small audience | harder to set up (routing, metrics) |
Rolling: [v1][v1][v1] → [v2][v1][v1] → [v2][v2][v1] → [v2][v2][v2]
Blue-green: blue(v1) active | green(v2) warmed up → switch LB to green
Canary: 95% of traffic → v1, 5% → v2; grow the share when metrics look good
⚠️ Gotcha: with rolling and canary, two versions of the application run at the same time — the API and DB schema must be backward compatible. Blue-green with a shared DB also requires schema compatibility. Canary is pointless without good monitoring: someone has to decide whether the rollout is "healthy."
21Artifacts and dependency caching in CI
middle
Short answer: An artifact is the output of a pipeline run (a Docker image, binary, bundle, coverage report) that is saved and passed between jobs or downloaded. A cache is data reused across runs (downloaded dependencies, build layers) to make the pipeline faster.
In depth:
The difference: artifacts are the output of the pipeline (needed as a result), while cache is an optimization (can be lost harmlessly — it just rebuilds more slowly).
# GitHub Actions: dependency cache
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt') }}
# Artifact between jobs
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
# GitLab CI
build:
cache:
key: { files: [package-lock.json] }
paths: [node_modules/]
artifacts:
paths: [dist/]
expire_in: 1 week
Docker layer caching in CI speeds up image builds:
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=max
⚠️ Gotcha: the cache key must include a hash of the lock file (requirements.txt, package-lock.json), otherwise you'll update a dependency but CI will keep pulling the old cache → flaky builds. And don't put secrets or large artifacts in the cache — it's often stored without strict limits and can "go stale."
22Linux: file system and permissions
junior
Short answer: In Linux there's a single tree rooted at / (no C:/D: drives). Every file has an owner (user), a group, and permissions for three categories — owner/group/others — each with three rwx bits (read/write/execute). Permissions are changed with chmod, ownership with chown.
In depth:
Key directories: /etc (configs), /var (logs, variable data), /home (home directories), /usr (programs), /tmp (temporary), /proc and /sys (virtual kernel file systems), /bin, /opt.
Permissions in ls -l output:
-rwxr-xr-- 1 alice devs 4096 Jun 24 10:00 script.sh
│└┬┘└┬┘└┬┘
│ │ │ └── others: r-- (read)
│ │ └───── group: r-x (read + execute)
│ └──────── owner: rwx (everything)
└────────── type: - file, d directory, l link
Numeric notation (r=4, w=2, x=1):
chmod 755 script.sh # rwx r-x r-x — executable file/script
chmod 644 file.txt # rw- r-- r-- — a regular file
chmod 600 secret.key # rw- --- --- — owner only (private key)
chmod +x deploy.sh # add execute permission
chown alice file.txt # change the owner
chown alice:devs file.txt # owner and group
chown -R www-data:www-data /var/www # recursively for the whole directory
⚠️ Gotcha: chmod 777 ("everything for everyone") is almost always bad advice from the internet, a security hole; the real cause of the problem is usually the wrong owner (chown). For a directory, the x bit means "you can enter / access files inside," not "execute." An SSH key with permissions broader than 600 will be rejected by the client.
23Useful command-line commands
junior
Short answer: Navigation (ls/cd/cp/mv/rm), viewing processes (ps/top/htop), searching (find/grep), text processing (awk/sed), logs (tail -f), disk (df/du), network (curl/wget/ssh), archives (tar), process management (kill).
In depth:
# Navigation and files
ls -lah # detailed, hidden files, human-readable sizes
cp -r src/ dst/ # recursive copy
mv old new # move/rename
rm -rf dir/ # delete recursively (CAREFUL)
# Processes
ps aux | grep nginx # all processes, filter by name
top # real-time monitoring (htop is nicer)
# Search
find /var/log -name "*.log" -mtime -1 # .log files changed in the last 24h
find . -type f -size +100M # files larger than 100 MB
grep -rni "error" /var/log/app/ # recursive case-insensitive search with line numbers
# Text processing
awk '{print $1, $7}' access.log # print the 1st and 7th columns
sed 's/foo/bar/g' file.txt # replace foo with bar
cut -d: -f1 /etc/passwd # first column split by the ":" delimiter
# Logs
tail -f /var/log/app.log # watch for new lines
tail -n 100 app.log | grep ERROR
journalctl -u myapp -f # logs of a systemd service in real time
# Disk
df -h # free space by partition
du -sh ./* # size of each item in the current folder
du -sh /var/log # total size of a directory
# Network
curl -s https://api.example.com/health # request (quiet mode)
curl -i -X POST -d '{"a":1}' -H "Content-Type: application/json" URL # POST with a header
wget https://example.com/file.tar.gz
ssh -i ~/.ssh/key.pem user@host # connect using a key
# Archives
tar -czf backup.tar.gz dir/ # create a gzip archive (create zip file)
tar -xzf backup.tar.gz # extract (extract zip file)
tar -tzf backup.tar.gz # view the contents
⚠️ Gotcha: rm -rf doesn't ask for confirmation and has no trash bin — rm -rf / or an accidental space (rm -rf / tmp/foo) will wipe the system. Mnemonic for tar: create / extract, z = gzip, f = file. And kill <pid> sends SIGTERM, it doesn't "kill instantly."
24Processes, signals, daemons, systemd
middle
Short answer: Every process has a PID. Signals are how you control processes: SIGTERM (15) asks it to shut down gracefully, SIGKILL (9) kills it immediately and irrevocably, SIGINT (2) is Ctrl+C, and SIGHUP (1) often means "reread the config." Daemons are background services; on modern Linux they're managed by systemd.
In depth:
# Signals
kill <pid> # SIGTERM — politely ask it to shut down (graceful)
kill -9 <pid> # SIGKILL — kill immediately (can't be ignored/handled)
kill -HUP <pid> # SIGHUP — often a config reload (e.g., nginx -s reload)
pkill -f gunicorn # by name/pattern
# Background processes
./long-task & # run in the background
jobs # background jobs of the current session
nohup ./task & # don't terminate when leaving the session (ignore SIGHUP)
disown # detach the job from the shell
SIGTERM vs SIGKILL:
- SIGTERM — the process can intercept it, finish requests, close connections, save state → graceful shutdown. This is the "right" way to stop.
- SIGKILL — sent by the kernel; the process can't intercept or ignore it; instant death with no cleanup → possible data loss and dangling connections.
docker stop sends SIGTERM, waits a grace period (10 s by default), then SIGKILL.
systemd is an init system and service manager (PID 1):
systemctl start myapp
systemctl stop myapp
systemctl restart myapp
systemctl enable myapp # auto-start at boot
systemctl status myapp
journalctl -u myapp --since "1 hour ago" # the service's logs
Unit file /etc/systemd/system/myapp.service:
[Unit]
Description=My App
After=network.target
[Service]
ExecStart=/usr/bin/gunicorn app:app --bind 0.0.0.0:8000
Restart=always
User=appuser
[Install]
WantedBy=multi-user.target
⚠️ Gotcha: kill -9 is a last resort, not a first step. If you reach for SIGKILL, the app won't get a chance to gracefully finish transactions/close files → data corruption, unreleased locks. Use SIGTERM first, and only resort to SIGKILL if the process is "hung" and unresponsive.
25Pipes and redirection (stdin, stdout, stderr)
junior
Short answer: A process has three standard streams: stdin (0, input), stdout (1, normal output), stderr (2, errors). The pipe | directs one command's stdout into another's stdin. Redirections: > (overwrite a file), >> (append), 2>&1 (merge stderr into stdout).
In depth:
# Pipe: stdout on the left → stdin on the right
cat access.log | grep ERROR | wc -l # count lines with ERROR
# Redirect stdout to a file
echo "hello" > out.txt # overwrite
echo "more" >> out.txt # append to the end
# Streams by number: 0=stdin, 1=stdout, 2=stderr
command > out.log 2> err.log # output and errors to separate files
command > all.log 2>&1 # both stdout and stderr to one file
command 2>/dev/null # throw errors into the void
command &> all.log # bash: everything to one file (shorthand for >file 2>&1)
# stdin from a file
sort < names.txt
# Combinations
ps aux | grep python | awk '{print $2}' | xargs kill # find and kill processes
journalctl -u myapp 2>&1 | tail -50
⚠️ Gotcha: the order in > file 2>&1 matters! It means "stdout → file, then stderr → wherever stdout currently points (file)." But 2>&1 > file first points stderr at the current stdout (the terminal), and only then sends stdout to the file — stderr stays on screen. Also, cmd | grep loses cmd's exit code (the exit code of the last command is used; see set -o pipefail).
26Environment variables (export, .bashrc, PATH)
junior
Short answer: Environment variables are key=value pairs available to processes. export VAR=value makes a variable available to child processes. PATH is the list of directories where the shell looks for executable commands. Persistent settings go into .bashrc/.profile/.zshrc.
In depth:
# Set a variable (visible only to the current shell)
MY_VAR=hello
# Export it (visible to child processes)
export MY_VAR=hello
export DATABASE_URL="postgres://localhost/app"
echo $MY_VAR # read it
env # all environment variables
printenv PATH # a specific one
# For a single command only
DEBUG=1 python app.py
# PATH — where to look for commands
echo $PATH # /usr/local/bin:/usr/bin:/bin:...
export PATH="$HOME/bin:$PATH" # add your own folder to the front
which python # show which binary will run
Persistent variables go in the shell's init files:
# ~/.bashrc (bash, interactive) or ~/.zshrc (zsh)
export PATH="$HOME/.local/bin:$PATH"
export EDITOR=vim
source ~/.bashrc # apply changes without logging out
⚠️ Gotcha: without export, a variable is visible only to the current shell and is not inherited by child processes/scripts. Changes in .bashrc don't apply to already-open sessions — you need source or a new terminal. Adding "." (the current folder) to PATH is a security risk — you could accidentally run a malicious ls from the current directory.
27Which process is listening on a port (lsof, netstat, ss)
middle
Short answer: You can find out which process is using a port with ss, lsof, or netstat. The modern recommendation is ss (faster, replaces the deprecated netstat).
In depth:
# ss — the modern tool
ss -tulpn # TCP+UDP, listening, with PID and ports (no name resolution)
ss -tlpn | grep :8000 # who's listening on port 8000
# lsof — list open files/sockets
lsof -i :8000 # the process on port 8000
lsof -i -P -n | grep LISTEN
# netstat (deprecated, but still common)
netstat -tulpn | grep :80
# ss/netstat flags: t=tcp, u=udp, l=listening, p=process(pid), n=numeric, a=all
A typical "Address already in use" scenario:
ss -tlpn | grep :8000 # find the PID holding the port
# users:(("python",pid=12345,fd=3))
kill 12345 # free the port
⚠️ Gotcha: to see another user's process (PID) on a port you often need root (sudo ss -tulpn), otherwise the process column will be empty. On macOS the flags differ — there lsof -i :PORT is more reliable. Ports below 1024 (privileged, e.g. 80/443) require root to listen on.
28What is nginx?
junior
Short answer: nginx is a high-performance web server and reverse proxy. It's used for: serving static files, proxying requests to a backend, load balancing across multiple instances, TLS termination (HTTPS), caching, and rate limiting. It's built on an event-driven (async) model and holds tens of thousands of connections.
In depth:
Main roles:
- Reverse proxy — accepts requests from clients and forwards them to the backend application, hiding it.
- Load balancer — distributes requests across multiple application instances (
upstream). - Serving static files — nginx serves CSS/JS/images itself, more efficiently than the application.
- TLS termination — decrypts HTTPS; from there plain HTTP travels to the application inside the protected network.
A basic config:
# Pool of backend servers for load balancing
upstream backend {
server 127.0.0.1:8000;
server 127.0.0.1:8001;
# least_conn; # strategy: to the least-loaded one
}
server {
listen 80;
server_name example.com;
# Serve static files directly
location /static/ {
root /var/www/myapp;
expires 30d;
}
# Proxy to the backend
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTPS + TLS termination
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://backend;
}
}
nginx -t # check config syntax
nginx -s reload # reread the config with no downtime (SIGHUP)
systemctl reload nginx
⚠️ Gotcha: proxy_pass http://backend without forwarding headers (Host, X-Forwarded-For, X-Forwarded-Proto) results in the application seeing nginx's IP instead of the real client and not knowing the connection was over HTTPS (breaking redirects, link generation, IP-based rate limiting). The difference between proxy_pass with and without a trailing slash changes how the path is rewritten.
29nginx vs WSGI/ASGI server (gunicorn/uvicorn)
middle
Short answer: These are different layers; you need both. nginx is the front-facing web server/proxy (TLS, static files, load balancing, protection). gunicorn/uvicorn is the application server that actually runs your Python code via the WSGI standard (synchronous, Django/Flask) or ASGI (asynchronous, FastAPI). The chain: client → nginx → gunicorn/uvicorn → application.
In depth:
Who does what:
- nginx — rapidly accepts many connections, serves static files, terminates TLS, protects against slow clients (slowloris), buffers, and load-balances. Written in C, event-driven model.
- gunicorn/uvicorn — runs workers with your code, manages their lifecycle, and talks to the application over WSGI/ASGI. It's not designed to "face the internet" on its own.
Client → [nginx :443] TLS, static files, load balancing
│ HTTP
▼
[gunicorn :8000] worker manager
│ WSGI/ASGI
▼
[Django / FastAPI application]
# WSGI (Django/Flask) — synchronous
gunicorn myproject.wsgi:application --bind 127.0.0.1:8000 --workers 4
# ASGI (FastAPI/Starlette) — asynchronous
uvicorn app:app --host 127.0.0.1 --port 8000 --workers 4
# or gunicorn with uvicorn workers in production:
gunicorn app:app -k uvicorn.workers.UvicornWorker --workers 4
WSGI vs ASGI: WSGI is the synchronous standard (one request per worker at a time), ASGI is asynchronous and supports websockets and long-lived connections.
⚠️ Gotcha: don't expose gunicorn/uvicorn's dev server directly to the internet without nginx — and the built-in dev server (flask run, python manage.py runserver, uvicorn --reload) is for development only, can't handle load, and is insecure. And the number of workers isn't "more is better": the rule of thumb is 2 * CPU + 1, otherwise you'll run out of memory.
30Kubernetes in brief
middle
Short answer: Kubernetes (k8s) is a container orchestration system: it automatically deploys, scales, restarts, and distributes containers across a cluster of machines. Core objects: Pod (the smallest unit — one or more containers), Deployment (manages pod replicas and updates), Service (a stable network endpoint to reach the pods).
In depth:
- Pod — the smallest deployable unit, one or more tightly coupled containers sharing network/storage. Ephemeral.
- Deployment — declaratively describes "I want N replicas of such-and-such image," keeps watch over their count, and performs rolling updates and rollbacks.
- Service — a stable virtual IP/DNS name for a group of pods (pods come and go, the Service stays). Load-balances traffic across the pods.
- Ingress — routes HTTP(S) traffic from outside into services (often built on nginx).
apiVersion: apps/v1
kind: Deployment
metadata: { name: myapp }
spec:
replicas: 3
selector: { matchLabels: { app: myapp } }
template:
metadata: { labels: { app: myapp } }
spec:
containers:
- name: myapp
image: registry.example.com/myapp:1.4.2
ports: [{ containerPort: 8000 }]
When you need it: many services, a need for autoscaling, self-healing, zero-downtime rollouts, running on a cluster of many machines. When you don't need it: one or two containers, a small project — k8s is overkill; docker-compose or a managed platform (PaaS) is enough.
⚠️ Gotcha: Kubernetes is enormous complexity (networking, RBAC, storage, monitoring). Dragging it into a small project is a common over-engineering mistake. For a junior interview it's enough to understand pod/deployment/service and that k8s handles orchestration; you don't need to go deep.
31Cloud in brief (compute, storage, managed db)
middle
Short answer: The cloud provides infrastructure on demand. The basic categories: compute (computation — VMs/instances, e.g. AWS EC2), storage (object storage S3, block storage EBS), managed database (a managed DB — RDS). A managed service means the provider takes on the routine (setup, backups, updates, fault tolerance) and you just use it.
In depth:
Categories using AWS as an example (GCP/Azure have equivalents):
- Compute — EC2 (VMs), Lambda (serverless functions), ECS/EKS (containers).
- Storage — S3 (object storage, for files/backups/static assets), EBS (disks attached to VMs), EFS (network file system).
- Managed DB — RDS (PostgreSQL/MySQL), DynamoDB (NoSQL), ElastiCache (Redis).
- Networking — VPC, Load Balancer, CloudFront (CDN).
Managed vs self-hosted:
- Self-hosted — you install PostgreSQL on a VM yourself, set up replication, backups, updates, and monitoring yourself. Cheaper in money, more expensive in time and risk.
- Managed (RDS) — the provider handles backups, patches, failover, and one-click scaling. Pricier, but it lifts the operational burden. You don't have access to the server's OS.
⚠️ Gotcha: managed services bring vendor lock-in and sometimes hidden costs (egress traffic, IOPS). Also, "managed" doesn't mean "you don't have to think about backups/security" — you're responsible for the access configuration (security groups, whether an S3 bucket is public). A public S3 bucket with data is a classic leak.
32Monitoring and logs conceptually
middle
Short answer: Observability rests on three pillars: metrics (numeric measurements over time — CPU, RPS, latency), logs (text events), and traces (a request's path through services). A typical stack: Prometheus (metrics collection) + Grafana (dashboards/visualization), and ELK (Elasticsearch + Logstash + Kibana) or Loki for logs.
In depth:
- Metrics (Prometheus + Grafana) — Prometheus periodically scrapes (pull) application metrics from an HTTP
/metricsendpoint and stores them as time series. Grafana builds graphs and alerts on top. Metrics are for trends and alerting ("latency went up," "the disk is filling up"). - Logs (ELK / Loki) — centralized collection of logs from all services into one place, with search and filtering (Kibana/Grafana). Containers write to stdout, and a collector (Fluentd/Filebeat/Promtail) ships it to storage.
- Traces (Jaeger / OpenTelemetry) — track a single request's path through microservices and locate the bottleneck.
- Alerting (Alertmanager) — notifications (Slack/PagerDuty) when rules fire.
Useful concepts: SLI/SLO/SLA (indicators/targets/commitments on reliability), and the "four golden signals" — latency, traffic, errors, saturation.
⚠️ Gotcha: logging without structure and centralization is useless during an incident — you need structured (JSON) logs with a correlation/request id to tie together the events of a single request across services. And don't log secrets/personal data (passwords, tokens, card numbers) — that's both a leak and a compliance violation (GDPR/PCI).
33Why Docker if there's venv?
concept
Short answer: venv isolates only Python packages within a single OS. Docker isolates the entire environment: the Python/OS version, system libraries (libpq, libssl), system packages, environment variables, network configuration. venv won't save you if the server has a different version of a system library, or a different OS entirely.
In depth:
venv answers the question "which Python packages and which interpreter" inside an already-existing system. But real applications depend on more:
- system libraries (
libpqfor psycopg,libjpeg,ffmpeg); - the version of the OS itself and its packages;
- adjacent services (Postgres, Redis) — Docker/compose brings those up too;
- consistency across the developer's laptop, CI, and production.
| venv | Docker | |
|---|---|---|
| Python packages | yes | yes |
| Python version | yes (if installed) | yes (in the image) |
| System libraries | no | yes |
| Whole OS/environment | no | yes |
| Adjacent services (DB) | no | yes (compose) |
| Portability to another OS | no | yes |
⚠️ Gotcha: Docker doesn't always make venv inside the image redundant, but usually you don't need a venv inside a container — the container is isolation in itself. The key point for an interview: venv is isolation of language dependencies, Docker is isolation of the whole environment and infrastructure — these solve different levels of the "works on my machine" problem.
34Why bother with CI/CD at all?
concept
Short answer: To turn releases from a rare, manual, scary event into frequent, automatic, and safe ones. CI/CD catches bugs early (automated tests on every commit), eliminates manual deploy errors, speeds up feature delivery, and makes rollbacks predictable.
In depth:
Without CI/CD: code is merged in big chunks, tests are run "when someone remembers," the deploy is a manual script done "from memory" that one senior engineer runs on Fridays, and if something breaks — panic. Releases are rare and risky → every release is huge → which makes them even riskier (a vicious cycle).
With CI/CD:
- Early detection — tests/lint on every PR; broken code never reaches main.
- Repeatability — the deploy is described as code, done the same way every time, not dependent on a person.
- Speed — features reach users faster, feedback comes faster.
- Less risk per release — small frequent changes are easier to roll back and easier to debug.
- Documentation — pipeline as code = it's clear what is built/deployed and how.
⚠️ Gotcha: CI/CD without good tests and monitoring is just "fast delivery of bugs to production." Automation amplifies both good and bad practices. First reliable tests and observability, then automatic deployment to production.
35What does nginx do in front of the application?
concept
Short answer: nginx sits "out front" and takes on what the application handles poorly or shouldn't be doing: TLS termination, serving static files, load balancing across instances, protection from slow/malicious clients, buffering, compression, rate limiting. The application deals only with business logic.
In depth:
Why not "the application directly on the internet":
- TLS termination — nginx decrypts HTTPS once, offloading cryptography from the application; certificates live in one place.
- Static files — nginx serves CSS/JS/images many times more efficiently than an application server, without bothering Python/Node.
- Load balancing — one public address with a pool of application instances behind it; nginx distributes the load and takes failed ones out.
- Protection — buffering protects the application's workers from slow clients (slowloris), rate limiting from brute force, and request-size limits.
- A single edge — headers, gzip, CORS, http→https redirects, healthchecks — all at the edge.
location /static/ { root /var/www; expires 1y; } # static files — itself
location / {
limit_req zone=api burst=20; # rate limit
proxy_pass http://app_backend; # dynamic content — to the application
client_max_body_size 10M; # size limit
}
⚠️ Gotcha: a common misconception is that nginx "speeds up Python code." It doesn't speed up the application's computation — it takes the non-core work off it (IO, static files, TLS, connections) and distributes the load. nginx won't fix a bottleneck in the code/DB itself.
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.