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.