Website service target setup (#2)

This PR contains the work done to add and setup the *Website* service target, a **Hummingbird** server app, into the Xcode project as a SwiftPM package with full support for containerization and driven by a `Makefile` file.

To provide further details about the work done:

* Swift package — SwiftPM manifest with a Website executable, related library, and test targets; depends on **hummingbird** and **swift-configuration**.
* Containerization — Multi-stage `Dockerfile` producing a static-linked release build with jemalloc, running as a non-root user on port 8080. Production and local-dev `docker-compose` files included.
* Configuration — `.env.local` template (with `.env` git-ignored) and `.dockerignore`/`.gitignore` entries.
* Makefile — Self-documenting operational commands:
  * pkg — SwiftPM: _build, release, test, clean, reset, deps, outdated, update_
  * img — Docker lifecycle: _build, mount, unmount, release_

Notes

* New service only — no changes to existing code; nothing else in the repo is affected.
* App logic is currently scaffolding; this PR establishes the service structure, build, and deployment tooling.

Reviewed-on: rock-n-code/loud-amsterdam#2
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-06-27 02:26:33 +00:00
committed by javier
parent a15271772e
commit 08b210e7c6
14 changed files with 489 additions and 0 deletions
+4
View File
@@ -47,6 +47,10 @@ playground.xcworkspace
# NOTE: `Package.resolved` is intentionally NOT ignored — committing it locks
# dependency versions across the team and CI.
# Docker / environment
.env
!.env.local
# Fastlane
fastlane/report.xml
fastlane/Preview.html
+6
View File
@@ -0,0 +1,6 @@
.build
.swiftpm
.env.local
docker-compose.*
Makefile
README.md
+42
View File
@@ -0,0 +1,42 @@
# Copy this file to `.env` and adjust values as needed.
# cp .env.example .env
#
# Compose reads `.env` automatically to fill the ${VAR} placeholders in
# docker-compose.yml. The Website app ALSO reads a `.env` file at runtime via
# swift-configuration (allowMissing: true), so any extra app config keys placed
# here are picked up by the running service too.
# --- Image / deployment -------------------------------------------------------
# Host name of the container running the Website service.
HOST_CONTAINER=containers.rock-n-code.com
# Owner of the container running the Website service.
HOST_OWNER=loud
# Password for authenticating to the container registry.
HOST_PASSWORD=
# Host port mapped to the container's port 8080.
HOST_PORT=8080
# User name for authenticating to the container registry.
HOST_USER=
# Name of the Docker image to pull/run.
IMAGE_NAME=website
# Platform of the Docker image to pull/run.
IMAGE_PLATFORM=linux/arm64
# Tag of the image to pull/run.
# Use a semver in production; avoid `latest` so rollbacks are unambiguous.
IMAGE_TAG=latest
# --- Application config -------------------------------------------------------
# Server name reported by the HTTP service.
HTTP_SERVER_NAME=LoudWebsite
# Log verbosity: trace | debug | info | notice | warning | error | critical
LOG_LEVEL=info
+87
View File
@@ -0,0 +1,87 @@
# ================================
# Build image
# ================================
FROM swift:6.3-noble AS build
# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get install -y libjemalloc-dev \
&& rm -rf /var/lib/apt/lists/*
# Set up a build area
WORKDIR /build
# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve
# Copy entire repo into container
COPY . .
# Build the application, with optimizations, with static linking, and using jemalloc
RUN swift build -c release \
--product "Website" \
--static-swift-stdlib \
-Xlinker -ljemalloc
# Switch to the staging area
WORKDIR /staging
# Copy main executable to staging area
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Website" ./
# Copy static swift backtracer binary to staging area
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./
# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;
# Copy any resouces from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.
RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true
# ================================
# Run image
# ================================
FROM ubuntu:noble
# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
# libcurl4 \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
&& rm -r /var/lib/apt/lists/*
# Create a hummingbird user and group with /app as its home directory
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird
# Switch to the new home directory
WORKDIR /app
# Copy built executable and any staged resources from builder
COPY --from=build --chown=hummingbird:hummingbird /staging /app
# Provide configuration needed by the built-in crash reporter and some sensible default behaviors.
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static
# Ensure all further commands run as the hummingbird user
USER hummingbird:hummingbird
# Let Docker bind to port 8080
EXPOSE 8080
# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./Website"]
CMD ["--http-host", "0.0.0.0", "--http-port", "8080"]
+107
View File
@@ -0,0 +1,107 @@
# Website service — operational commands
# --- Configuration ------------------------------------------------------------
# Load configuration from .env (the same file Compose reads).
ENV_FILE := $(if $(wildcard .env),.env,.env.local)
include $(ENV_FILE)
export
# Variables
IMAGE_URL := $(HOST_CONTAINER)/$(HOST_OWNER)/$(IMAGE_NAME)
# Arguments
override version ?= $(IMAGE_TAG)
# Show help if no target is specified.
.DEFAULT_GOAL := help
# --- Swift package ------------------------------------------------------------
.PHONY: pkg-build
pkg-build: ## Build the Swift package
@swift build
.PHONY: pkg-release
pkg-release: ## Release the Swift package
@swift build -c release
.PHONY: pkg-test
pkg-test: ## Run the Swift package tests
@swift test \
--disable-xctest \
--enable-code-coverage \
--enable-swift-testing \
--parallel
.PHONY: pkg-clean
pkg-clean: ## Remove the Swift build artifacts
@swift package clean
.PHONY: pkg-reset
pkg-reset: ## Resets the complete SPM cache/build folder
@swift package reset
.PHONY: pkg-deps
pkg-deps: ## Lists the SPM package dependencies
@swift package show-dependencies
.PHONY: pkg-outdated
pkg-outdated: ## Lists the SPM package dependencies that can be updated
@swift package update --dry-run
.PHONY: pkg-update
pkg-update: ## Updates the SPM package dependencies
@swift package update
# --- Local development --------------------------------------------------------
.PHONY: img-build
img-build: ## Build the local dev image
@docker compose build
.PHONY: img-mount
img-mount: ## Mount the service locally (build if needed)
@docker compose up --build --detach
.PHONY: img-unmount
img-unmount: ## Unmount and remove the local service
@docker compose down
@$(MAKE) img-remove
# --- Registry deployment ------------------------------------------------------
.PHONY: img-release
img-release: ## Build the production (amd64) image, tag with version + latest, push both
@if [ -z "$(version)" ] || [ "$(version)" = "latest" ]; then \
echo "Error: 'version' must be an explicit tag — e.g. make img-release version=1.2.3"; \
exit 1; \
fi
@docker build \
--platform $(IMAGE_PLATFORM) \
--tag $(IMAGE_URL):$(version) \
--tag $(IMAGE_URL):latest \
.
@echo "${HOST_PASSWORD}" \
| docker login $(HOST_CONTAINER) \
--username $(HOST_USER) \
--password-stdin
@docker push $(IMAGE_URL):$(version)
@docker push $(IMAGE_URL):latest
@docker logout $(HOST_CONTAINER)
@$(MAKE) img-remove
.PHONY: img-remove
img-remove: # Removes the generated Docker images
@images="$$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep '$(IMAGE_NAME)' | awk '{print $$1}')"; \
if [ -n "$$images" ]; then \
docker image rm --force $$images; \
else \
echo "No '$(IMAGE_NAME)' images to remove."; \
fi
# --- Meta ---------------------------------------------------------------------
.PHONY: help
help: ## Show available commands
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
+74
View File
@@ -0,0 +1,74 @@
// swift-tools-version:6.3
import PackageDescription
let package = Package(
name: "Website",
platforms: [
.macOS(.v15),
.iOS(.v18),
.tvOS(.v18),
],
products: [
.executable(
name: "Website",
targets: [
"Website",
"WebsiteCore",
]
)
],
dependencies: [
.package(
url: "https://github.com/hummingbird-project/hummingbird.git",
from: "2.25.0"
),
.package(
url: "https://github.com/apple/swift-configuration.git",
from: "1.0.0",
traits: [
.defaults,
"CommandLineArguments",
]
),
],
targets: [
.executableTarget(
name: "Website",
dependencies: [
.product(
name: "Configuration",
package: "swift-configuration"
),
.product(
name: "Hummingbird",
package: "hummingbird"
),
],
path: "Sources/App"
),
.target(
name: "WebsiteCore",
dependencies: [],
path: "Sources/Library"
),
.testTarget(
name: "WebsiteTests",
dependencies: [
.product(
name: "HummingbirdTesting",
package: "hummingbird"
),
.byName(name: "Website"),
],
path: "Tests/App"
),
.testTarget(
name: "WebsiteCoreTests",
dependencies: [
.byName(name: "WebsiteCore"),
],
path: "Tests/Library"
),
]
)
+2
View File
@@ -0,0 +1,2 @@
# Website
Hummingbird server framework project
@@ -0,0 +1,62 @@
import Configuration
import Hummingbird
import Logging
/// Build application
/// - Parameter reader: configuration reader
func application(
reader: ConfigReader
) async throws -> some ApplicationProtocol {
let logLevel = reader.string(
forKey: "log.level",
as: Logger.Level.self,
default: .info
)
let serverName = reader.string(
forKey: "http.serverName",
default: "LoudWebsite"
)
return Application(
router: try router(),
configuration: ApplicationConfiguration(
reader: reader.scoped(to: "http")
),
logger: logger(
serverName: serverName,
logLevel: logLevel
)
)
}
// MARK: - Helpers
// Request context used by application
private typealias AppRequestContext = BasicRequestContext
/// Build logger
private func logger(
serverName: String,
logLevel: Logger.Level
) -> Logger {
var logger = Logger(label: serverName)
logger.logLevel = logLevel
return logger
}
/// Build router
private func router() throws -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
router.addMiddleware {
LogRequestsMiddleware(.info)
}
router.get("/") { _, _ in
return "Hello!"
}
return router
}
+28
View File
@@ -0,0 +1,28 @@
import Configuration
import Hummingbird
import Logging
@main
struct App {
static func main() async throws {
let reader = try await ConfigReader(
providers: [
CommandLineArgumentsProvider(),
EnvironmentVariablesProvider(),
EnvironmentVariablesProvider(
environmentFilePath: ".env",
allowMissing: true
),
InMemoryProvider(values: [
"http.serverName": "LoudWebsite"
]),
]
)
let app = try await application(
reader: reader
)
try await app.runService()
}
}
@@ -0,0 +1,2 @@
+34
View File
@@ -0,0 +1,34 @@
import Configuration
import Hummingbird
import HummingbirdTesting
import Logging
import Testing
@testable import Website
private let reader = ConfigReader(providers: [
InMemoryProvider(values: [
"http.host": "127.0.0.1",
"http.port": "0",
"log.level": "trace",
])
])
@Suite
struct AppTests {
@Test
func hello() async throws {
let app = try await application(
reader: reader
)
try await app.test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
#expect(response.body == ByteBuffer(string: "Hello!"))
}
}
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,18 @@
# Local development overrides.
# Compose merges this file on top of docker-compose.yml automatically, so a
# plain `docker compose up` builds from source instead of pulling a registry image:
#
# docker compose up --build # build locally and run
# docker compose up -d # reuse the last local build
#
# It reuses the `image:` name from the base file, so the local build is tagged
# the same way the production image would be.
services:
website:
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
platform: linux/arm64
build:
context: .
dockerfile: Dockerfile
environment:
LOG_LEVEL: debug
+22
View File
@@ -0,0 +1,22 @@
name: loud-platform
# Production base configuration.
# Deploys a pre-built image pulled from a registry — no build step.
#
# docker compose -f docker-compose.yml pull
# docker compose -f docker-compose.yml up -d
#
# The `-f docker-compose.yml` flag is important in production: it skips the
# docker-compose.override.yml file, which Compose would otherwise merge in
# automatically for local development.
services:
website:
image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
platform: linux/amd64
container_name: ${HOST_OWNER}-${IMAGE_NAME}
restart: unless-stopped
ports:
- "${HOST_PORT:-8080}:8080"
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}