1. A file path as a service contract
We have already brought order to the export directory: the service receives it from the outside and has stopped guessing where to write. But that is only where the file scenario stops being random — it does not become reliable yet. When a developer exports to a file for the first time, the usual temptation is to just “quickly write a string to catalog.csv and enjoy life.” And that is normal — as long as we live in a world with one OS, one machine, and one run. But as soon as Docker and mounts appear, the path suddenly becomes a contract: where exactly the file will appear, who will see it, which characters are allowed, and whether the process can write there at all.
The trickiest part is that path and permission errors look like “Java broke” or “Spring broke.” In reality, it is often not business logic that breaks, but the environment around it: the container path does not exist, a bind mount covered another directory, the file name contains : (hello, Windows), the directory is read-only, and the application fails honestly because it cannot write to stone.
Let us fix one idea that saves nerves: in the application there is a “container path” (target path), and Docker has a “host path” (source path). These are different things, and mixing them up is like confusing a delivery address with a store address.
For a visual, here is a simple diagram:
flowchart TD Host["Host OS (Windows/macOS/Linux) project directory: ./data/exports"] -->|"bind mount (source)"| Container["Container FS /app/exports (target)"] Container -->|"APP_EXPORT_DIR=/app/exports"| App["Spring Boot app writes CSV file"] App --> File["/app/exports/catalog-2026-03-21T10-15-00.csv"]
The application knows only the target path inside the container (for example, /app/exports). Docker knows where this is mapped on the host (for example, ./data/exports). If you try to give the application a host path like C:\Users\me\project\data\exports, while it runs in a Linux container, things quickly turn into a very sad stand-up routine, just without the laughs.
2. Path and resolve instead of strings
Concatenating paths with strings is one of those “youthful sins” that are not forbidden, but then bite you later. A typical version looks harmless: "dir" + "/" + "file". The problem is that you manually take responsibility for separators (/ vs \), extra slashes, .. in the middle, and code readability. And also for the fact that somewhere a dir + file without a slash will definitely appear, and then there will be a mysterious bug: “why is the path wrong?”
In Java, there is a proper model for paths: java.nio.file.Path. It can join path segments, understands the separators of the current platform, and most importantly, makes code readable. It is like stopping carrying water in a sieve: theoretically possible, but why.
Example of “what NOT to do”
// Export directory (as a string)
String exportDir = "/app/exports";
// File name (as a string)
String fileName = "catalog.csv";
// Manual concatenation: easy to forget a slash, get a double slash, or break on another OS
String path = exportDir + "/" + fileName;
System.out.println(path); // /app/exports/catalog.csv
This works in a Linux-like environment, but even here you are manually handling the separator and do not control the fact that exportDir may arrive with a trailing /.
Example of “what to do” (Path.of + resolve)
import java.nio.file.Path;
// Directory as Path (this is already a path model, not “just a string”)
Path exportDir = Path.of("/app/exports");
// Add a file name as a path “segment” (without manual slashes)
Path file = exportDir.resolve("catalog.csv");
System.out.println(file); // /app/exports/catalog.csv
Pay attention: resolve literally reads as “resolve the file relative to the directory.” This is very human language — and that is half the success.
A small table to reinforce the idea:
| Task | Strings | Path |
|---|---|---|
| Join a directory and a file name | easy to make a mistake with / | exportDir.resolve(fileName) |
| Cross-platform compatibility | you have to remember \ | Path sorts it out on its own |
| Normalization (.., .) | you have to write it manually | path.normalize() |
| Checks (exists? directory?) | through File/strings, pain | Files.* + |
And yes, you can use File.separator, but that is more of a patch than a solution. Path is the proper architectural habit.
3. Relative and absolute paths
When you write Path.of("data", "exports"), Java creates a relative path. Relative means “from the process’s current working directory.” In a local IDE run, that is usually the project root (but not always), and in a container it is usually the WORKDIR value from the Dockerfile (for example, /app). And this is where beginners hit the first “file paradox”: the same code suddenly writes to different places because the working directory is different.
So the basic rule is very simple: a relative path is fine if you consciously control what it is resolved from. In a training project, we often want exports from a local run to land in the repository (for example, ./data/exports), and in a container — in a container directory (for example, /app/exports) with the option to mount it on the host.
An important nuance: APP_EXPORT_DIR for the application is a path inside the process, that is, inside the container (when we are in the container). Do not try to turn APP_EXPORT_DIR into “a path on your laptop.” That is a Docker-level concern, not a Java-code-level concern.
Look at an example that shows the difference well:
import java.nio.file.Path;
// Relative path: resolved from the current working directory of the process
Path local = Path.of("data", "exports");
System.out.println(local.toString()); // data/exports (or data\exports on Windows)
// Absolute path (Linux style): often used as a stable target inside a container
Path container = Path.of("/app/exports");
System.out.println(container.toString()); // /app/exports
Both paths are valid; they simply refer to different run modes. In local mode, the relative path is convenient because it is not tied to your C:\Users\... or /Users/.... In container mode, an absolute path inside the container is convenient because it is stable and does not depend on where you run docker run.
And one more small but useful technique: inside the code, it is almost always useful to normalize the path, so that at least logs show you “what came out.”
import java.nio.file.Path;
// toAbsolutePath(): to see the full path in logs and not guess “from where is it resolved”
// normalize(): to remove extra "./" and "dir/.."
Path dir = Path.of("data/exports").toAbsolutePath().normalize();
System.out.println(dir); // /.../project/data/exports
If you have ever asked “why is the file not there,” you will appreciate how much time this saves on guessing.
4. Safe file names
File names are often treated as “well, just a string.” But in real life they are input data (sometimes even from a user), and file systems have character. And different file systems have different characters: Linux is usually more tolerant, Windows is much stricter, and macOS has its own quirks. So “it works in my container” does not guarantee “it works for the student on Windows who wants to see the export on the host.”
The most common surprise: you take LocalDateTime.now().toString() and get a string with colons in the time, for example 2026-03-21T10:15:00. On Linux, a colon in a file name is usually allowed; on Windows, it is not. Result: the export “suddenly” fails, even though the code is “absolutely correct.”
A bad (but very popular) file name variant
import java.time.LocalDateTime;
// LocalDateTime.toString() contains ":" in the time part — on Windows this is a forbidden character in a file name
String fileName = "catalog-" + LocalDateTime.now() + ".csv";
System.out.println(fileName); // catalog-2026-03-21T10:15:00.csv
From Java’s point of view, this is just a string. From Windows’ point of view, it is an attempt to insert a forbidden character.
A good variant: DateTimeFormatter without forbidden characters
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
// Format specifically without ":" (we replace it with "-") so the name is cross-platform
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");
String fileName = "catalog-" + LocalDateTime.now().format(fmt) + ".csv";
System.out.println(fileName); // catalog-2026-03-21T10-15-00.csv
Here we replaced : with - specifically in the time format. The result is readable, sortable, and cross-platform.
Sanitizing a name that came from somewhere else
Even if we generate the name ourselves, it is useful to have a small function that removes obvious problems. There is no need to turn this into a “600-line file name library”; a small, honest minimum is enough for a training project.
public class FileNameSanitizer {
public String sanitize(String raw) {
// Minimal “sanitization”: remove the most common problematic characters
return raw
.replace(":", "-")
.replace("/", "_")
.replace("\\", "_");
}
}
Yes, that is simplistic. And yes, in a real production system you can discuss Unicode normalization and other joys. But in a training project, the goal is different: remove the most common surprises so exports stop failing “just because of a character.”
And one more thing that many people forget: a file name and a directory are different things. First you choose a directory, then you add the file name with resolve. If you keep this in a single string like "data/exports/catalog.csv", you will inevitably get bugs where you accidentally duplicate part of the path or replace the directory.
5. normalize() and protection against ../
Now comes the part where you feel a little bit like a grown-up engineer: even if the course does not have a separate security track, files are where security and “just being careful” meet without warning.
The problem looks like this: you think you were given a file name catalog.csv, but you were given (accidentally or intentionally) ../../oops.txt. If you simply do exportDir.resolve(fileName), you will get a path outside the export directory. Sometimes it is just a user mistake, sometimes it is an unpleasant attempt to write a file “somewhere else.” In any case, the service should be able to catch it.
The solution is simple and very illustrative: resolve + normalize, followed by a startsWith(exportDir) check.
import java.nio.file.Path;
// Base export directory (what we consider the “allowed zone”)
Path exportDir = Path.of("/app/exports").normalize();
// A “file name” that unexpectedly turned out to be a path escaping upward
String rawName = "../oops.txt";
// resolve() joins the path, normalize() tries to collapse ".." and "."
Path candidate = exportDir.resolve(rawName).normalize();
// Check that after normalization the path still starts with exportDir
if (!candidate.startsWith(exportDir)) {
throw new IllegalArgumentException("Invalid export file name: " + rawName);
}
It is important to understand the mechanics here: normalize() removes .. and . at the path level, where possible. And startsWith(exportDir) checks that the resulting path is still inside the needed directory. This is a good “safety belt” even for a training project: it protects against accidents and makes behavior predictable.
You can wrap this nicely in a small class so that business code does not spread everywhere:
import java.nio.file.Path;
public class ExportPathResolver {
private final Path exportDir;
public ExportPathResolver(Path exportDir) {
this.exportDir = exportDir.normalize();
}
public Path resolve(String fileName) {
Path file = exportDir.resolve(fileName).normalize();
if (!file.startsWith(exportDir)) {
throw new IllegalArgumentException("File escapes export dir: " + fileName);
}
return file;
}
}
The code is short, the logic is in one place, and now CatalogExportService can think about exporting instead of how not to fly into the neighboring directory.
6. Write permissions: how to tell “cannot write” from “the export broke”
When the application cannot write a file, a beginner developer often starts suspecting everything: CSV generation, encoding, “the wrong Spring profile,” “Docker magic again.” In practice, it is often more banal: the directory does not exist, the directory exists but it is a file, the directory is there but read-only, the directory is mounted but the container does not have write permissions, or the file system in the environment itself does not allow writing.
A good engineering habit here is to validate the directory in advance and fail with a clear message before you start “doing the export.” Then the error will look like “export directory is not writable” instead of “something is wrong somewhere in the middle.”
Here is a minimal directory validator (no overengineering):
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class ExportDirectoryValidator {
public Path prepare(String configuredDir) throws IOException {
// Convert the path to an absolute and normalized form so it is understandable in logs
Path dir = Path.of(configuredDir).toAbsolutePath().normalize();
// Create directories if needed (the operation is idempotent)
Files.createDirectories(dir);
// Check write permission in advance so the error is “human” and early
if (!Files.isWritable(dir)) {
throw new IllegalStateException("Export directory is not writable: " + dir);
}
return dir;
}
}
Files.createDirectories(dir) is convenient because it is idempotent: if the directory already exists, it does not pretend everything is ruined. And Files.isWritable(dir) gives a quick signal: “can we really write there?”
The exceptions you will see most often
In Java, file-system problems often involve AccessDeniedException, NoSuchFileException, and sometimes FileSystemException. For a beginner, it helps to see at least once that these are different error classes: the first is about permissions, the second is about a missing path, and the third is a general container for “the file system said no.”
You can write simple handling that turns a “scary error” into a clear message without exposing too many details:
import java.nio.file.AccessDeniedException;
import java.nio.file.NoSuchFileException;
public class ExportErrorMapper {
public String toHumanMessage(Exception e) {
if (e instanceof AccessDeniedException) {
return "No permission to write to the export directory";
}
if (e instanceof NoSuchFileException) {
return "Directory or file not found (check the path and mount)";
}
return "File system error: " + e.getClass().getSimpleName();
}
}
This is not “great architecture,” but as a training habit — excellent. Especially when the application is in a container and you are trying to figure out whether it is a code error or an environment error.
7. Bind mount: cross-platform nuances
File scenarios do not fail only because of Java. Bind mounts can add their own surprises, especially if the team works on different OSes. Here it is important to know just a few things — enough not to waste half a day asking “why does this not work,” but not enough for the course to suddenly become a Linux course.
First nuance: Docker distinguishes between source and target. target is the path inside the container (almost always Linux style, for example /app/exports). source is the path on the host (can be a Windows path, a macOS path, or a relative project path). And APP_EXPORT_DIR must match the target specifically, otherwise the application will write somewhere other than where you mounted it.
Second nuance: an explicit --mount type=bind usually expects the source path to already exist. If you specify a directory that does not exist, Docker will not always “politely create” it for you. That is good: the error is early and clear, instead of something appearing “not there and not right.” (The short -v syntax can behave differently in some environments, but in a training context it is better to get used to explicitness.)
Third nuance: a Windows path with C: conflicts with the colon in the old volume mapping syntax. So on Windows it is often easier to use either --mount ... or carefully escape/quote strings. We are not going to dive into the shell zoo, but it is useful to remember: if you see an error where Docker “did not understand” the path, it is not always your fault — sometimes it is just the command-line syntax.
To avoid writing three separate courses called “Bash / PowerShell / CMD,” it is enough to remember the idea: pass the source path so that it is valid for your shell, and always make the target path inside the container a Linux absolute path.
For example, the mounting logic itself may look like this (conceptually):
docker run --rm \
--mount type=bind,source=./data/exports,target=/app/exports \
-e APP_EXPORT_DIR=/app/exports \
your-image:tag
There is nothing “about Windows” or “about Linux” in the target — it is stable. All the “platform-ness” lives in the source, and that is normal: the host is always different.
8. Mini refactoring of the export service
Safe filename, normalize(), and writable checks are useful exactly where the service actually writes the file. The export directory still comes from app.export.dir, so no new configuration style is needed. Here we care about two local things: a safe file name and a guarantee that the final path does not escape the export directory.
It is still convenient to generate the file name separately — for example, through DateTimeFormatter without :. And inside the existing CatalogExportService, which receives ExportProperties props, writing can be strengthened like this:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public Path writeCsv(String fileName, String csv) throws IOException {
Path exportDir = ExportDirectoryValidator.prepare(props.dir().toAbsolutePath().normalize());
Path file = exportDir.resolve(fileName).normalize();
if (!file.startsWith(exportDir)) {
throw new IllegalArgumentException("Bad file name: " + fileName);
}
return Files.writeString(file, csv, StandardCharsets.UTF_8);
}
This gives an important result: the directory still comes from one source of truth, and the service becomes more resilient to broken names, strange relative paths, and unexpected write problems.
When the directory, file name, and permissions stop being random, the only thing left to check is the main one: does the result survive replacing the container, or does it still live only until the first docker rm.
9. Typical path-handling mistakes
Mistake #1: concatenating paths with strings and “slashes by mood”.
It usually starts with dir + "/" + name, and ends with one place using dir + name, another with two slashes in a row, and on Windows a \ suddenly appears and part of the code starts living its own life. Path.of(...) and resolve(...) remove this class of problems almost completely, and at the same time make the code easier to read.
Mistake #2: confusing the host path (source) with the container path (target) and trying to feed the application C:\....
Inside a container there is almost always a Linux file system, and the string C:\temp\exports in such an environment does not mean “drive C.” It is just a strange directory name with a colon and backslashes that you will create yourself (if you are lucky with permissions). The application should receive a path inside the container, for example /app/exports, and the host path lives only in the Docker command as source.
Mistake #3: generating a file name with LocalDateTime.toString() and hitting forbidden characters.
On Linux, colons usually pass; on Windows, they do not. As a result, the export “broke only for part of the group,” and a witch hunt begins. A stable DateTimeFormatter with the HH-mm-ss format removes the problem while keeping the name sortable and readable.
Mistake #4: not normalizing the path and not checking startsWith(exportDir).
If ../ somehow gets into the file name (by mistake or from external input), resolve(fileName) can take you outside the export directory. That is bad unpredictability even for a training service. normalize() + startsWith() is a small protection that makes the result obvious and manageable.
Mistake #5: thinking that “if the directory exists, then writing is possible.”
Permissions are a separate layer of reality. A directory may exist but be read-only, may be mounted without write permissions, may be a file system that forbids changes, or may simply belong to a user that the process cannot access. A quick validation through Files.createDirectories(...) and Files.isWritable(...) gives a clear error before you disappear into an investigation.
GO TO FULL VERSION