If you were asked to package HTML (and other static assets) in a Docker container, how would you achieve it? Sure, you can spin up a Node.js or Python application and create a web server that serves the required assets, but that would be a roundabout method of solving a simple problem. Of course, Nginx is a viable option, but which will you choose?

$ docker image ls
REPOSITORY   TAG                 IMAGE ID       CREATED        SIZE
nginx        latest              53a18edff809   7 weeks ago    192MB
nginx        stable-alpine-slim  95ebc828d689   7 weeks ago    11.8MB

Of course you’d choose nginx:stable-alpine-slim. It’s perhaps the most slimmed-down version of Nginx that can at least serve HTML files. Since you do not plan to do any reverse proxying, this can serve your purpose. Think of cases where you are building a purely static site. You could use this image in the release step to avoid bloat.

Building with it is easy. Have your HTML file (and other assets if necessary), copy it to the /usr/share/nginx/html directory, and profit. Here’s the Dockerfile:

FROM nginx:stable-alpine-slim

WORKDIR /usr/share/nginx/html

COPY index.html .

In the end, you get a ~12 MB Docker image serving your static assets. The overall size depends on the size of those static assets themselves. If you have a 200 mb compiled static asset directory, then don’t expect your image to be anything less than 200 MB 😅.

Can we go slimmer?

We cannot exactly go slimmer with Nginx because we are already running on alpine Linux. We could, however, write a C program that serves the static HTML. By compiling this program and setting it within alpine, we could run it as a mini web server, serving our static assets. See this as a case similar to that of Nginx alpine slim, but the web server within the alpine image is 237 times [1] smaller. To run this particular example, you can compile the C program below and move it to a Dockerfile.

The snippet below outlines the alpine Dockerfile with the slimserver.

FROM alpine:latest

WORKDIR /usr/local/bin

COPY slimserver /usr/local/bin
COPY index.html /var/www/html

CMD ["slimserver", "--file", "/var/www/html/index.html"]

The snippet below is the C program (LLM generated, sorry), that takes an HTML file path as an argument and serves it using Native C:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define DEFAULT_PORT 80
#define BUFFER_SIZE 1024
#define DEFAULT_HTML "index.html"

int main(int argc, char *argv[]) {
    int server_fd, client_fd, port = DEFAULT_PORT;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];

    // Default file
    const char *html_file_path = DEFAULT_HTML;

    // Parse command-line arguments
    for (int i = 1; i < argc; i++) {
        if (strcmp(argv[i], "--file") == 0 && i + 1 < argc) {
            html_file_path = argv[++i];  // Set file from argument
        } else if (strcmp(argv[i], "--port") == 0 && i + 1 < argc) {
            port = atoi(argv[++i]);  // Set port from argument
            if (port <= 0 || port > 65535) {
                fprintf(stderr, "Invalid port number: %d\n", port);
                exit(EXIT_FAILURE);
            }
        } else {
            fprintf(stderr, "Usage: %s [--file path/to/file.html] [--port port_number]\n", argv[0]);
            exit(EXIT_FAILURE);
        }
    }

    // Create socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) {
        perror("Socket creation failed");
        exit(EXIT_FAILURE);
    }

    // Configure server address
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(port);

    // Bind socket to port
    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("Bind failed");
        exit(EXIT_FAILURE);
    }

    // Listen for connections
    if (listen(server_fd, 5) < 0) {
        perror("Listen failed");
        exit(EXIT_FAILURE);
    }

    printf("Server running on http://localhost:%d, serving %s\n", port, html_file_path);

    while (1) {
        // Accept a connection
        client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        if (client_fd < 0) {
            perror("Accept failed");
            continue;
        }

        // Read HTTP request (ignored for simplicity)
        read(client_fd, buffer, BUFFER_SIZE);

        // Open the specified HTML file
        FILE *html_file = fopen(html_file_path, "r");
        if (!html_file) {
            perror("Failed to open HTML file");
            close(client_fd);
            continue;
        }

        // Send HTTP response headers
        char response_headers[] = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n";
        write(client_fd, response_headers, strlen(response_headers));

        // Send HTML file content
        while (fgets(buffer, BUFFER_SIZE, html_file) != NULL) {
            write(client_fd, buffer, strlen(buffer));
        }

        fclose(html_file);
        close(client_fd);
    }

    close(server_fd);
    return 0;
}

The resulting docker image is 7.88 MB (which is the size of my html + the slimserver compiled C program).

REPOSITORY                          TAG                  IMAGE ID       CREATED             SIZE
slimserver                          latest               580e45f997a2   4 seconds ago       7.88MB

Conclusion

Chasing minimal, packaged software that can serve your daily needs is like a religion. It’s something you are committed to and would spend hours researching smaller, slimmer, and more beautiful ways to deliver the smallest, most performant software. This article explores the idea of packaging HTML as a docker image in the slimmest possible way. It started by exploring how one can use the slimmest Nginx image and concluded with a way to serve HTML from barebones alpine Linux using a compiled C program.

There are certainly slimmer ways to serve static files. One can consider using /dev/tcp or starting from a scratch docker image and add the most minimal busybox utilities. Maybe we can achieve a 1 MB docker image that serves HTML just as well as the slim Nginx image.

Footnotes

[1] The byte size of the nginx alpine slim image is 11782955 while that of alpine linux is 7834312. A rough difference gives the relative size of the Nginx alpine slim binary [nginxslimsize = (11782955 - 7834312)]. In comparison, the byte size of the slim server is 16640 [slimserversize = 16640]. So by percentages (nginxslimsize/slimserversize) * 100 = 23,729.83%, or ~ 23730% larger. This percentage is unnecessary so we could simply it to 237 times larger.