docker/Dockerfile
Rama master · réplica de lectura del repositorio original.
# Global build arguments
ARG SMAPI_VERSION=4.5.2
# ============================================================================
# Stage 1: Build steam-service downloader
# ============================================================================
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS steam-service-builder
WORKDIR /src
# Repo-level build props + code-style rules apply inside the image build too
COPY ./Directory.Build.props ./CodeStyle.props ./.editorconfig ./
COPY ./tools/steam-service/*.csproj ./
RUN dotnet restore
COPY ./tools/steam-service/ .
RUN dotnet publish -c Release -o /app --no-restore
# ============================================================================
# Stage 2: Download game files using steam-service (filtered, no Content folder)
# ============================================================================
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS game-downloader
# Copy steam-service from builder
COPY --from=steam-service-builder /app /steam-service
WORKDIR /steam-service
# Download game (skip SDK during build, not needed for compiling the mod and downloaded at runtime via setup)
RUN --mount=type=secret,id=steam_username \
--mount=type=secret,id=steam_password \
--mount=type=secret,id=steam_refresh_token \
if [ -f /run/secrets/steam_username ]; then export STEAM_USERNAME="$(cat /run/secrets/steam_username)"; fi && \
if [ -f /run/secrets/steam_password ]; then export STEAM_PASSWORD="$(cat /run/secrets/steam_password)"; fi && \
if [ -f /run/secrets/steam_refresh_token ]; then export STEAM_REFRESH_TOKEN="$(cat /run/secrets/steam_refresh_token)"; fi && \
export GAME_DIR=/game && \
dotnet SteamService.dll download --skip-sdk
# ============================================================================
# Stage 3: Build SMAPI from source with the patch series in patches/smapi applied
# ============================================================================
# The runtime image installs this build into the game volume at boot (init_smapi in
# docker/rootfs/startapp.sh); the mod-builder stage installs it for compilation.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS smapi-builder
ARG SMAPI_VERSION
# zip keeps the Unix permission bits inside install.dat (the SDK image has git but no zip)
RUN apt-get update && apt-get install -y --no-install-recommends zip && rm -rf /var/lib/apt/lists/*
COPY --from=game-downloader /game /game
RUN git clone --depth 1 --branch "${SMAPI_VERSION}" https://github.com/Pathoschild/SMAPI.git /smapi
# A patch that no longer applies to the pinned upstream fails the build here, not silently at runtime
COPY patches/smapi /smapi-patches
RUN for patch in /smapi-patches/*.patch; do \
echo "Applying ${patch}" && git -C /smapi apply --check "${patch}" && git -C /smapi apply "${patch}"; \
done
COPY --chmod=0755 docker/smapi/build-installer.sh /smapi-build.sh
RUN --mount=type=cache,target=/root/.nuget/packages \
/smapi-build.sh /smapi /game /smapi-patches "${SMAPI_VERSION}" /out
# ============================================================================
# Stage 4: Build mod using game DLLs from stage 2 and SMAPI from stage 3
# ============================================================================
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS mod-builder
ARG BUILD_CONFIGURATION=Debug
# Mod compilation resolves StardewModdingAPI.dll from the game folder, so install our build there
COPY --from=game-downloader /game /game
COPY --from=smapi-builder /out /tmp/smapi
RUN /tmp/smapi/internal/linux/SMAPI.Installer --no-prompt --install --game-path /game && \
rm -rf /tmp/smapi
# Copy project files first for better layer caching
# (.editorconfig: code-style rules enforced at build via EnforceCodeStyleInBuild)
COPY ./Directory.Build.props ./CodeStyle.props ./.editorconfig /src/
COPY ./mod/JunimoServer/JunimoServer.csproj /src/mod/JunimoServer/
COPY ./mod/JunimoServer.Shared/JunimoServer.Shared.csproj /src/mod/JunimoServer.Shared/
# Restore dependencies with cache mount
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore /src/mod/JunimoServer/JunimoServer.csproj
# Build dll-patcher tool
COPY ./tools/dll-patcher /src/dll-patcher
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish /src/dll-patcher \
--configuration Release \
--self-contained true \
--runtime linux-x64 \
-p:PublishSingleFile=true \
-p:PublishTrimmed=true \
-o /output/dll-patcher
# Build netdebug tool
COPY ./tools/netdebug /src/netdebug
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish /src/netdebug \
--configuration Release \
--self-contained true \
--runtime linux-x64 \
-p:PublishSingleFile=true \
-p:PublishTrimmed=true \
-o /output/netdebug
# Build diagnostics tool
COPY ./tools/diagnostics /src/diagnostics
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish /src/diagnostics \
--configuration Release \
--self-contained true \
--runtime linux-x64 \
-p:PublishSingleFile=true \
-p:PublishTrimmed=true \
-o /output/diagnostics
# Build server mod
COPY ./mod /src/mod
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet build /src/mod/JunimoServer/JunimoServer.csproj \
--configuration ${BUILD_CONFIGURATION} \
/p:GamePath=/game \
-o /output/JunimoServer
# Build openapi-generator tool and generate OpenAPI spec from mod
COPY ./tools/openapi-generator /src/openapi-generator
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet build /src/openapi-generator/openapi-generator.csproj \
--configuration Release \
-o /output/openapi-generator && \
dotnet /output/openapi-generator/openapi-generator.dll \
/output/JunimoServer/JunimoServer.dll \
/output/openapi.json
# Build the E2E test-fixture farm mod (needs game DLLs, hence built here). It is NOT a
# loaded mod in normal operation — it's copied into /data/Mods only on servers that opt in
# via SDVD_TEST_FIXTURE_FARM_MOD (see docker/rootfs/startapp.sh). manifest.json is copied
# alongside the dll because EnableModDeploy=false leaves it in the project dir, not /output.
COPY ./tests/fixtures/TestFarmMod /src/tests/fixtures/TestFarmMod
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet build /src/tests/fixtures/TestFarmMod/TestFarmMod.csproj \
--configuration ${BUILD_CONFIGURATION} \
/p:GamePath=/game \
-o /output/TestFarmMod && \
cp /src/tests/fixtures/TestFarmMod/manifest.json /output/TestFarmMod/manifest.json
# ============================================================================
# Stage 5: Final runtime image (no game files, downloads on first run)
# ============================================================================
FROM --platform=$BUILDPLATFORM jlesage/baseimage-gui:debian-13-v4.13 AS server
ARG TMUX_VERSION=3.7c
ENV APP_NAME="Stardew Dedicated Server" \
USER_ID=1000 \
GROUP_ID=1000 \
HOME=/config \
LANG=en_US.UTF-8 \
LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8 \
ACCEPT_EULA=Y \
DEBIAN_FRONTEND=noninteractive \
TERMINAL=xterm \
DARK_MODE=1 \
# Display
DISPLAY=:0 \
DISPLAY_HEIGHT=720 \
DISPLAY_WIDTH=1280 \
SERVER_FPS=0 \
# SMAPI
SMAPI_USE_CURRENT_SHELL=true \
SMAPI_MODS_PATH=/data/Mods \
# Pid file of startapp.sh's phase responder; read by the mod (ApiPortHandoff) and the HEALTHCHECK
API_HANDOFF_PID_FILE=/tmp/phase-responder.pid
# add-pkg (base image helper), not raw apt-get: it pre-creates /var/log's symlink target
# (/config — a runtime volume, absent at build time, so tcpdump's postinst fails without it)
# and installs --no-install-recommends, keeping out systemd, whose postinst dies on that
# same symlink (mesa-utils recommends it). add-pkg cleans the apt cache itself.
RUN add-pkg \
# Base packages
ca-certificates \
curl \
# .NET globalization (SMAPI FailFasts at startup without it)
libicu76 \
# SkiaSharp's native image decoder (Content Patcher EditImage) needs fontconfig to load
libfontconfig1 \
locales \
procps \
xz-utils \
# Additional packages
htop \
nano \
tcpdump \
# attach-cli input pane runs on zsh/ZLE (ghost text + TAB completion; server-command-loop)
zsh \
# TigerVNC
tigervnc-standalone-server \
mesa-utils \
&& echo "en_US.UTF-8 UTF-8" > /etc/locale.gen \
&& locale-gen \
# Configure TigerVNC
&& cp /usr/bin/Xvnc /opt/base/bin/Xvnc
# Install ffmpeg 8.1.2 (BtbN build of the stable 8.1 branch) rather than the distro package, whose
# x11grab timing bugs break the test-recording pipeline. Pin month-end autobuilds only: BtbN prunes
# daily builds after 14 days (month-end builds live two years), and the "latest" tag drifts.
RUN curl -fL --retry 5 --retry-delay 2 -o /tmp/ffmpeg.tar.xz https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-30-13-34/ffmpeg-n8.1.2-21-gce3c09c101-linux64-gpl-8.1.tar.xz && \
tar -xf /tmp/ffmpeg.tar.xz -C /tmp && \
install -m 0755 /tmp/ffmpeg-n8.1.2-21-gce3c09c101-linux64-gpl-8.1/bin/ffmpeg /usr/local/bin/ffmpeg && \
install -m 0755 /tmp/ffmpeg-n8.1.2-21-gce3c09c101-linux64-gpl-8.1/bin/ffprobe /usr/local/bin/ffprobe && \
rm -rf /tmp/ffmpeg*
# Install tmux (binary download, separate layer for cacheability)
# -f + --retry: the GitHub release fetch occasionally fails the TLS handshake (curl exit 35) on CI
# runners; retry the transient network error instead of failing the whole build
RUN curl -fL --retry 5 --retry-delay 2 -O https://github.com/tmux/tmux-builds/releases/download/v${TMUX_VERSION}/tmux-${TMUX_VERSION}-linux-x86_64.tar.gz && \
tar -xzf tmux-${TMUX_VERSION}-linux-x86_64.tar.gz && \
install -m 0755 ./tmux /usr/local/bin/tmux && \
rm -r ./tmux tmux-${TMUX_VERSION}-linux-x86_64.tar.gz
# Install vsdbg for remote debugging
# TODO: Make installation optional for local/debug builds, commented for now
# RUN curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /root/vsdbg
# Install WebVNC icon
COPY docker/rootfs/data/images/junimo.png /data/images/junimo.png
RUN install_app_icon.sh /data/images/junimo.png && \
rm /data/images/junimo.png
# System cleanup and configuration
RUN \
# Fix stuck dbus statoverride
rm -f /var/lib/dpkg/statoverride && \
# Prevent unnecessary logs
rm -f /etc/cont-init.d/89-info.sh && \
# Prevent overriding OpenGL drivers
rm -rf /etc/cont-env.d/LIBGL_DRIVERS_PATH && \
# Pin the runtime HOME so Steam's ~/.steam lookup matches where startapp.sh links
# steamclient.so (${HOME}/.steam/sdk64); a mismatch fails SteamAPI_Init and with it
# GameServer.Init(). /config is owned by USER_ID at startup, so it stays writable for any
# configured non-root user. set-cont-env is used because cont-env overrides container env,
# so HOME can't be supplied from compose.
set-cont-env HOME /config
# Copy pre-built tools from build stages
COPY --from=smapi-builder /out /opt/smapi
COPY --from=mod-builder /output/dll-patcher /opt/dll-patcher
COPY --from=mod-builder /output/netdebug /opt/netdebug
COPY --from=mod-builder /output/diagnostics /opt/diagnostics
# Set executable permissions for application scripts and Supervisor services.
#
# Custom service definitions are copied to temp, chmod'd (non-empty files only), then merged into
# /etc/services.d after copying all system files. This avoids modifying base image service files.
# Note: .dep files can be executable scripts (return boolean) or empty markers. The ! -empty check
# makes only non-empty files executable, handling both cases correctly.
#
# xrdb/min_running_time=0 declares the xrdb one-shot as expected-to-exit-immediately (the same
# value the base image ships for its own always-exiting services — verified in the built
# image's /etc/services.d). cinit treats a service exiting
# before min_running_time (default 500ms) as a failed start and tears the whole container down
# (exit 1) — and xrdb (load X resources, exit) only outlives 500ms when it happens to spend that
# long waiting on a still-warming Xvnc, a timing race a one-shot must not have to win.
# min_running_time is a VALUE file cinit reads — it must stay non-executable, or cinit tries to
# run it (fails 126).
COPY docker/rootfs/etc/services.d /tmp/services/
RUN find /tmp/services -type f ! -empty ! -name min_running_time -exec chmod +x {} \; && \
find /tmp/services -type f -name min_running_time -exec chmod 0644 {} \;
# Copy system files (merges with base image)
COPY docker/rootfs/ /
# Apply our chmod'd services and set other script permissions
RUN cp -a /tmp/services/. /etc/services.d/ && \
rm -rf /tmp/services && \
chmod +x /startapp.sh /opt/base/bin/* /etc/cont-init.d/15-vnc-gate.sh /etc/cont-init.d/50-server-init.sh
# Copy mod from build stage (no game files included in image)
COPY --from=mod-builder /output/JunimoServer /data/Mods/JunimoServer
# Stage the E2E test-fixture farm mod OUTSIDE the loaded Mods path. startapp.sh copies it
# into /data/Mods/TestFarmMod only when SDVD_TEST_FIXTURE_FARM_MOD=true, so it never loads
# on normal servers (keeping Data/AdditionalFarms vanilla for everyone else).
COPY --from=mod-builder /output/TestFarmMod /opt/test-fixtures/TestFarmMod
# Copy OpenAPI spec (for extraction via: docker cp or docker run --rm sdvd/server cat /data/openapi.json)
COPY --from=mod-builder /output/openapi.json /data/openapi.json
# Git commit the image was built from (the diagnostics tool reports it). Declared last, not with the
# ARGs up top: its value changes every build and a changed ARG/ENV invalidates every layer after it —
# up top that re-ran the apt install and the ffmpeg/tmux downloads. Nothing in the build consumes it.
ARG SDVD_GIT_SHA=unknown
ENV SDVD_GIT_SHA=${SDVD_GIT_SHA}
# Game files and save data are in volumes (game downloaded at runtime)
VOLUME [ "/data/game", "/config/xdg/config/StardewValley" ]
# Expose HTTP API port for external tools and automated testing
EXPOSE 8080
# Unhealthy without connecting while the phase responder owns the port: it serves one connection at
# a time, and a 2s probe would keep stealing it from real status clients.
HEALTHCHECK --interval=2s --timeout=2s --start-period=120s --retries=15 \
CMD { [ ! -e "${API_HANDOFF_PID_FILE}" ] && curl -fsS --max-time 2 "http://127.0.0.1:${API_PORT:-8080}/health" > /dev/null; } \
|| [ "${API_ENABLED:-true}" = "false" ] || exit 1
WORKDIR /data