# 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