feat: Making it Dockerable
This commit is contained in:
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# ── Stage 1 : nothing to build, just copy static assets ──────────────────────
|
||||||
|
# Use nginx:alpine — ~8 MB, actively maintained, CVE-scanned by Docker Hub
|
||||||
|
FROM nginx:stable-alpine AS runtime
|
||||||
|
|
||||||
|
# Drop all Linux capabilities the HTTP server doesn't need.
|
||||||
|
# Only CAP_NET_BIND_SERVICE is kept when binding to port 80; since we expose
|
||||||
|
# 8080 (>1024) and run as non-root, we can drop everything.
|
||||||
|
# These are set at runtime via docker-compose; the label is informational.
|
||||||
|
LABEL org.opencontainers.image.title="it_portfolio" \
|
||||||
|
org.opencontainers.image.description="Static portfolio — nginx/alpine" \
|
||||||
|
org.opencontainers.image.authors="Gauvain Boiché"
|
||||||
|
|
||||||
|
# Replace the default nginx config with our hardened one
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Copy static assets
|
||||||
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
|
COPY content/ /usr/share/nginx/html/content/
|
||||||
|
COPY resources/ /usr/share/nginx/html/resources/
|
||||||
|
|
||||||
|
# Give the non-root nginx user ownership of what it needs at runtime.
|
||||||
|
# Temp files go to /tmp (1777 tmpfs), so no pre-creation needed.
|
||||||
|
RUN chown -R nginx:nginx \
|
||||||
|
/var/log/nginx \
|
||||||
|
/etc/nginx/nginx.conf \
|
||||||
|
/usr/share/nginx/html \
|
||||||
|
&& chmod -R 755 /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Switch to the built-in unprivileged nginx user
|
||||||
|
USER nginx
|
||||||
|
|
||||||
|
# Expose unprivileged port (no CAP_NET_BIND_SERVICE required)
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:8080/ || exit 1
|
||||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
portfolio:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: it_portfolio:latest
|
||||||
|
container_name: it_portfolio
|
||||||
|
|
||||||
|
# ── Networking ──────────────────────────────────────────────────────────
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# ── Resource limits (near-zero footprint for a static site) ─────────────
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "0.10" # 10 % of one core at most
|
||||||
|
memory: 32M
|
||||||
|
reservations:
|
||||||
|
cpus: "0.01"
|
||||||
|
memory: 8M
|
||||||
|
|
||||||
|
# ── Hardening ───────────────────────────────────────────────────────────
|
||||||
|
read_only: true # container filesystem is immutable
|
||||||
|
tmpfs: # /tmp is the only writable path nginx needs
|
||||||
|
- /tmp:size=16m,mode=1777
|
||||||
|
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true # prevent privilege escalation via setuid
|
||||||
|
cap_drop:
|
||||||
|
- ALL # drop every Linux capability…
|
||||||
|
# (no cap_add needed — port 8080 > 1024, user nginx, no raw sockets)
|
||||||
|
|
||||||
|
# ── Lifecycle ───────────────────────────────────────────────────────────
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
# ── Observability ───────────────────────────────────────────────────────
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "5m"
|
||||||
|
max-file: "3"
|
||||||
107
nginx.conf
Normal file
107
nginx.conf
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Hardened nginx config for a static portfolio
|
||||||
|
# Runs as user 'nginx' (non-root), listens on port 8080
|
||||||
|
|
||||||
|
worker_processes auto; # one worker per CPU core
|
||||||
|
worker_rlimit_nofile 1024; # tight file-descriptor cap
|
||||||
|
|
||||||
|
# Log to stdout/stderr so Docker can collect them
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
pid /tmp/nginx.pid; # writable by non-root user
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 512;
|
||||||
|
use epoll;
|
||||||
|
multi_accept on;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
# ── Temp paths writable by the nginx user ───────────────────────────────
|
||||||
|
# /tmp is a 1777 tmpfs — writable by any user, no subdirectory pre-creation needed
|
||||||
|
client_body_temp_path /tmp/client_temp;
|
||||||
|
proxy_temp_path /tmp/proxy_temp;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||||
|
scgi_temp_path /tmp/scgi_temp;
|
||||||
|
|
||||||
|
access_log /dev/stdout combined;
|
||||||
|
|
||||||
|
# ── MIME types ──────────────────────────────────────────────────────────
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# ── Performance ─────────────────────────────────────────────────────────
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 30;
|
||||||
|
keepalive_requests 100;
|
||||||
|
|
||||||
|
# ── Compression ─────────────────────────────────────────────────────────
|
||||||
|
gzip on;
|
||||||
|
gzip_static on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_types
|
||||||
|
text/plain text/css text/javascript application/javascript
|
||||||
|
application/json application/xml image/svg+xml
|
||||||
|
font/woff font/woff2;
|
||||||
|
|
||||||
|
# ── Security headers ────────────────────────────────────────────────────
|
||||||
|
server_tokens off; # hide nginx version
|
||||||
|
|
||||||
|
# ── Virtual host ────────────────────────────────────────────────────────
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers sent on every response
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||||
|
add_header Content-Security-Policy
|
||||||
|
"default-src 'self'; \
|
||||||
|
script-src 'self' 'unsafe-inline'; \
|
||||||
|
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
|
||||||
|
font-src 'self' https://fonts.gstatic.com; \
|
||||||
|
img-src 'self' data:; \
|
||||||
|
connect-src 'self'; \
|
||||||
|
frame-ancestors 'none';"
|
||||||
|
always;
|
||||||
|
|
||||||
|
# Cache static assets aggressively, HTML never
|
||||||
|
location ~* \.(css|js|woff2?|ico|svg|png|jpg|jpeg|gif|webp)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny access to hidden files
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Limit accepted HTTP methods
|
||||||
|
if ($request_method !~ ^(GET|HEAD)$) {
|
||||||
|
return 405;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cap request body (nothing to POST to a static site)
|
||||||
|
client_max_body_size 1k;
|
||||||
|
|
||||||
|
# Timeout tuning
|
||||||
|
client_header_timeout 10s;
|
||||||
|
client_body_timeout 10s;
|
||||||
|
send_timeout 10s;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user