Hardened Docker Guidelines
When creating deployable challenges on the FCTF platform, enabling the Harden Container feature enforces strict Kubernetes Pod Security Standards. This guide outlines how to structure and write a Dockerfile that ensures your challenge functions correctly inside this highly restricted, zero-trust runtime environment.
Directory Structure
Each challenge must be packaged within an isolated directory containing all source code and dependencies required to build the challenge. A Dockerfile is strictly required and must reside precisely at the root of this structure.
The entire directory content will be utilized as the build context when the platform's Argo workflows construct the container image.
Example Structure:
challenge-name/
├── Dockerfile
└── (additional files, source code, and directories defined by the author)
Once prepared, compress the folder into a .zip archive to upload it via the Create Challenge interface.
Mandatory Security Principles
Your Dockerfile must conform 100% to the following constraints. Any violation will cause the container to crash immediately upon execution inside the Kubernetes cluster.
- USER 1000: The container must execute as an unprivileged user with
UID 1000. The file must explicitly declareUSER 1000before theCMDorENTRYPOINT. - Read-only Root Filesystem: The entire root filesystem (
/) is mounted as read-only. - Isolated Writes: Applications can only write to
/tmp, which is mounted as an in-memorytmpfsvolume. Attempting to write to/data,/var,/etc, or/homeat runtime will trigger a fatalPermission Deniederror. - Stream Logs: Do not configure file-based logging (e.g.,
/var/log/*.log). All logs must be redirected tostdoutorstderr. - Single Expose: Expose and listen on exactly one standardized port per challenge spec.
- Zero Capabilities: The environment aggressively drops all Linux capabilities. Do not rely on
setcaporprivilegedexecution.
Standard Design Strategy
General Principles
- Keep runtime immutable: Treat the rootfs as strictly read-only.
- Symlink to
/tmp: Use symbolic links to route all required runtime write paths (logs, cache, temp files) into/tmp. - Explicit Ownership: Ensure all file permissions are granted to
UID 1000before theUSER 1000directive is called. - Pre-extract Artifacts: Separate build-time operations from runtime. Extract artifacts (like
.waror.jarfiles) during the build stage if possible.
Build Flow Lifecycle
Follow this precise sequence when crafting your Dockerfile:
- Use an appropriate Base Image (Alpine is highly recommended for minimum footprint).
- Create and add a user mapped to
UID 1000. - Install base dependencies.
- Copy the application source code or artifacts.
- Create symlinks pointing from application write paths to
/tmp. - Execute
chown -R 1000:1000across the application directory. - Switch execution context using
USER 1000. - In the
CMD, executemkdir -p /tmp/...to construct the target write directories before booting the application server.
Symlinks must be mapped during the BUILD stage (when the filesystem is still writable via RUN). During runtime, the CMD simply creates the physical /tmp/{target} folders right before executing the server workload.
Symlink Redirection Technique
This strategy is the technical foundation for surviving a read-only root filesystem. Any directory an application natively expects to be writable must be symbolically linked to /tmp.
General Pattern:
# Inside Dockerfile (Build time - filesystem is writable):
RUN rm -rf /path/to/writable \
&& ln -s /tmp/some-dir /path/to/writable
# Inside CMD (Runtime - construct target dirs before passing execution):
CMD ["sh", "-c", "mkdir -p /tmp/some-dir && exec myserver"]
Workload Templates
The following templates cover standard application stacks. Replace the bracketed placeholders (<...>) with your specific variables.
A. Static Server (Nginx)
Nginx natively attempts to write PIDs and logs to /var. Use sed to modify the default configuration directly within the image.
FROM nginx:alpine
# 1. Declare User and Port
ARG APP_USER=appuser
ARG PORT=8080
RUN adduser -D -u 1000 $APP_USER
# 2. Clear default config
RUN rm -rf /etc/nginx/conf.d/default.conf
# 3. Inject new server config block
RUN echo "server { \
listen ${PORT}; \
location / { \
root /usr/share/nginx/html; \
index index.html; \
try_files \$uri \$uri/ =404; \
} \
}" > /etc/nginx/conf.d/default.conf
# 4. Force Nginx to route all temporary scopes to /tmp
RUN sed -i 's|pid .*|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf \
&& sed -i 's|access_log .*|access_log /dev/stdout;|' /etc/nginx/nginx.conf \
&& sed -i 's|error_log .*|error_log /dev/stderr;|' /etc/nginx/nginx.conf \
&& sed -i "/http {/a \ client_body_temp_path /tmp/client_temp;\n proxy_temp_path /tmp/proxy_temp;\n fastcgi_temp_path /tmp/fastcgi_temp;\n uwsgi_temp_path /tmp/uwsgi_temp;\n scgi_temp_path /tmp/scgi_temp;" /etc/nginx/nginx.conf \
&& sed -i 's/^user/#user/' /etc/nginx/nginx.conf
# 5. Copy Source Directory
COPY ./html /usr/share/nginx/html
# 6. Apply strict permission boundaries
RUN chown -R 1000:1000 /usr/share/nginx/html /etc/nginx /var/cache/nginx
USER 1000
EXPOSE ${PORT}
# 7. Construct temp structure on boot
CMD ["sh", "-c", "mkdir -p /tmp/client_temp /tmp/proxy_temp /tmp/fastcgi_temp /tmp/uwsgi_temp /tmp/scgi_temp && exec nginx -g 'daemon off;'"]
B. Node.js Backend
Utilize slim images if native modules (like sqlite3) require build compilations.
FROM node:18-bullseye-slim
WORKDIR /app
# Optional: Install build dependencies for native modules
RUN apt-get update && \
apt-get install -y python3 build-essential --no-install-recommends && \
rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install --production
COPY . .
# Redirect writable scopes to /tmp
RUN rm -rf /app/uploads && ln -s /tmp/uploads /app/uploads || true
RUN rm -f /app/data.db && ln -s /tmp/data.db /app/data.db || true
# Map standard UID/GID
RUN groupadd -g 1000 appgroup || true && \
useradd -r -u 1000 -g appgroup -m -d /home/appuser -s /usr/sbin/nologin appuser || true
RUN mkdir -p /tmp/uploads && \
chown -R 1000:1000 /tmp /app
ENV PORT=3000
ENV TMPDIR=/tmp
ENV NODE_ENV=production
EXPOSE 3000
USER 1000
CMD ["sh","-c","mkdir -p /tmp/uploads && exec node server.js"]
C. Python (Flask & FastAPI)
PYTHONUNBUFFERED=1 is strictly required to stream logs correctly. Frameworks like Gunicorn/Uvicorn can output directly to stdout without modifying application scripts.
Flask + Gunicorn:
FROM python:3.11-alpine
RUN adduser -D -u 1000 appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Redirect writable paths (e.g., SQLite DB or uploads)
RUN rm -f /app/data.db && ln -s /tmp/data.db /app/data.db || true
RUN rm -rf /app/uploads && ln -s /tmp/uploads /app/uploads || true
RUN chown -R 1000:1000 /app
USER 1000
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV TMPDIR=/tmp
EXPOSE 5000
CMD ["sh","-c","mkdir -p /tmp/uploads && exec gunicorn app:app --bind 0.0.0.0:5000 --workers 2 --access-logfile -"]
D. Java (Spring Boot)
Leverage multi-stage builds. Extract native libraries (like SQLite JDBC) strictly during the build stage to bypass runtime write constraints.
# ── Build stage ──────────────────────────────────────────
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN mvn -q -DskipTests package
# Pre-extract native libraries to prevent runtime /tmp writes
RUN mkdir -p /native && \
jar xf target/*.jar BOOT-INF/lib/sqlite-jdbc-3.49.1.0.jar && \
jar xf BOOT-INF/lib/sqlite-jdbc-3.49.1.0.jar org/sqlite/native/Linux/x86_64/libsqlitejdbc.so && \
mv org/sqlite/native/Linux/x86_64/libsqlitejdbc.so /native/libsqlitejdbc.so
# ── Runtime stage ────────────────────────────────────────
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /native/libsqlitejdbc.so /usr/lib/libsqlitejdbc.so
COPY --from=build /build/target/*.jar /app/app.jar
USER 1000
ENV JAVA_TOOL_OPTIONS="\
-Djava.io.tmpdir=/tmp \
-Dorg.sqlite.lib.path=/usr/lib \
-Dorg.sqlite.lib.name=libsqlitejdbc.so"
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar", "--server.port=8080"]
E. PHP (Apache)
Apache must be constrained to bind onto higher unprivileged ports (e.g., 8080) and mapped explicitly to run within /tmp.
FROM php:8.2-apache
RUN apt-get update && apt-get install -y libsqlite3-dev sqlite3 && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install pdo_sqlite
# Offset to Unprivileged Port
RUN sed -i 's/Listen 80/Listen 8080/' /etc/apache2/ports.conf \
&& sed -i 's/:80/:8080/' /etc/apache2/sites-available/000-default.conf \
&& echo "ServerName localhost" >> /etc/apache2/apache2.conf
# Map Apache Internal Variables to TMP
ENV APACHE_RUN_DIR=/tmp/apache-run \
APACHE_PID_FILE=/tmp/apache-run/apache2.pid \
APACHE_LOCK_DIR=/tmp/apache-lock \
APACHE_LOG_DIR=/tmp/apache-logs
RUN groupadd -g 1000 appgroup || true && \
useradd -r -u 1000 -g appgroup -m -d /home/appuser -s /usr/sbin/nologin appuser || true
RUN chown -R 1000:1000 /var/www/html /etc/apache2 /var/log/apache2
WORKDIR /var/www/html
COPY . .
RUN chown -R 1000:1000 /var/www/html
USER 1000
EXPOSE 8080
CMD ["sh","-c", "mkdir -p $APACHE_RUN_DIR $APACHE_LOCK_DIR $APACHE_LOG_DIR /tmp/uploads && touch /tmp/data.db && exec apache2-foreground"]
Implementation Checklist & Anti-Patterns
Refer to this matrix to circumvent common configuration issues.
| Anti-Pattern (Avoid) | Hardened Practice (Adopt) |
|---|---|
Writing files directly to /app at runtime. | Refactor application logic to write directly to /tmp or invoke the TMPDIR env var. |
Leaving user default (root) in Dockerfile. | Explicitly append USER 1000 directly before execution. |
Creating Symlinks within the CMD. | Create Symlinks during the RUN ln -s build stage. |
Relying on outputting to /var/log/*.log. | Export all logs directly into /dev/stdout and /dev/stderr. |
Exposing standard Privileged Ports < 1024. | Utilize internal overlay routing by exposing 8080, 5000, 3000, etc. |
| Omitting folder creation upon startup. | Append mkdir -p /tmp/... to the front of your CMD execution string. |
| Forgetting to reassign file ownership. | Issue chown -R 1000:1000 /app prior to declaring USER 1000. |
Debugging Application Crashes
If your pod crashes immediately after initialization, track the error vectors:
- Examine container output logic via
docker logs <container_id>. - Determine if the app is attempting unauthorized writes looking for
/root permissions. - Identify unauthorized write paths: Run the image with
docker exec <cid> find / -newer /etc/hostname -not -path /tmp/\* -not -path /proc/\* 2>/dev/null
Mandatory Local Validation
Executing a quick test locally is imperative before committing the package zip. Run the following command. If the container succeeds without crashing locally, it is provisionally valid for upload.
docker build -t challenge-x .
# Validate strict constraints locally:
docker run --read-only --tmpfs /tmp --user 1000 -p 8080:8080 challenge-x
While the local docker run command accurately mimics runtime constraints, it does not guarantee ultimate compatibility with FCTF deployments. Upon successful package upload, always execute the built-in Preview Feature inside the Admin Portal to ensure total system integration.