A reflective look at my self-hosting journey, including Docker, Ghost migrations, database compatibility, email configuration, reverse proxy setup, and why the friction is still worth it.

Why I Still Self-Host, Even When It Gets Complicated

Self-hosting sounds simple until you actually do it.

At a high level, the idea is simple: instead of relying entirely on third-party platforms, I run some of my own services. I control the server, the containers, the data, the configuration, and the way everything fits together.

That is the clean version.

The real version includes broken containers, database errors, confusing logs, reverse proxy rules, email settings, SSL certificates, persistent volumes, and the occasional moment where you wonder why something that worked yesterday suddenly refuses to start today.

And yet, even with all of that friction, I still prefer self-hosting.

Not because it is always easier. It usually is not.

I prefer it because every problem teaches me something. Every hurdle gives me a better understanding of the systems I rely on. And every time I fix something, I gain a little more confidence in my ability to manage my own infrastructure.

Self-Hosting Is About Ownership

For me, self-hosting is not just about saving money or avoiding subscriptions. Sometimes it does save money. Sometimes it absolutely does not.

The bigger reason is ownership.

I like knowing where my data lives. I like understanding how my services are configured. I like being able to move things, rebuild things, back things up, and change things without waiting on someone else’s platform to allow it.

There is a certain peace of mind that comes from knowing the stack is mine, even when that stack is being difficult.

Managed services are convenient, and there is nothing wrong with using them. But convenience often comes with tradeoffs. You may not control the upgrade schedule. You may not have full access to the underlying system. You may be limited by pricing tiers, product decisions, or features that disappear because they no longer fit someone else’s roadmap.

Self-hosting shifts more responsibility onto me, but it also gives me more control.

That tradeoff is worth it.

Docker Makes Self-Hosting Approachable

Docker is one of the tools that makes modern self-hosting feel possible for regular people.

Instead of manually installing every dependency on a server, I can define services in a docker-compose.yml file and bring them up with a single command. The application gets its container. The database gets its container. The content directory gets mounted as a volume. The network between services is handled by Docker.

When it works, it feels clean and repeatable.

But Docker can also create a false sense of simplicity.

A container may be disposable, but the data behind it is not. That distinction matters. The application container can be deleted and recreated easily. But the database volume, uploaded files, configuration state, and application content persist.

That persistence is the whole point, of course. You do not want your blog posts, users, settings, or uploads disappearing every time a container restarts.

But persistence also means old mistakes can stick around.

A bad database state, an outdated schema, an old configuration choice, or an incompatible upgrade can survive across container rebuilds. You can restart the app ten times and still hit the same error because the real issue is not inside the container. It is inside the data the container is using.

That was one of the first lessons Docker taught me: containers are temporary, but state has memory.

Ghost and the Database Migration Problem

One of the more useful, and frustrating, examples came from running Ghost.

Ghost is a great publishing platform, and running it in Docker seems straightforward at first. You define a Ghost service, connect it to a database service, mount the content directory, set the site URL, and put it behind a reverse proxy.

In theory, that is enough to get moving.

In practice, I ran into a database migration issue that took me deeper into the stack than I expected.

Ghost started, attempted to run migrations, and then shut down after only a few seconds. The logs showed that the problem happened while Ghost was adding a new gifts table. Specifically, it failed when trying to add a foreign key  buyer_member_id to the members table.

The error looked like this:

errno: 150 "Foreign key constraint is incorrectly formed"

For a beginner, that kind of error can feel pretty opaque. It is not immediately obvious whether the problem is Ghost, Docker, the database, the data volume, or something else entirely.

After digging into it, the issue pointed toward database compatibility and schema expectations. I was using Ghost in Docker with a MariaDB container. MariaDB is often described as MySQL-compatible, and in many cases, that is true enough. But “compatible” does not always mean “identical,” especially when an application is running newer migrations that expect specific database behavior.

Foreign keys are picky. The column types need to match. The storage engine matters. Character sets and collations can matter. Nullability can matter. The existing table structure matters. And if the database has been carried forward through old versions or previous migrations, the schema may not be exactly what the application expects.

That experience taught me not to treat the database as a generic background service.

The database is the application's memory and structure. If it is not compatible with what the app expects, the entire service can fail before the website ever loads.

The Problem With Using latest

Another lesson I learned from this setup was that latest is convenient until it causes a problem.

Using image tags like ghost:latest or mariadb:latest feels natural when you are getting started. It sounds like the right thing to do. Why not use the newest version?

The problem is that latest is not a version strategy. It is a moving target.

If the application updates and the database updates independently, I may end up with a combination I did not intentionally choose. When something breaks, I am no longer troubleshooting a specific upgrade. I am trying to figure out what changed, when it changed, and whether the current versions are even meant to work together.

That is not where I want to be when I am trying to keep a service online.

Now I prefer to pin versions more deliberately. Instead of relying on latest, I want to know exactly what I am running. If I upgrade, I want that upgrade to be a decision, not a surprise.

A simple version pin can make troubleshooting much easier. It also makes backups, rollbacks, and documentation more meaningful.

Self-hosting has taught me that boring reliability usually beats exciting ambiguity.

Email Configuration Is Not Optional

Another hurdle I ran into was email.

It is easy to think of email as something separate from the application. The site loads, the admin dashboard works, posts can be published, and everything seems fine.

Then you need password resets, member emails, notifications, or reports.

That is when email becomes part of the core infrastructure.

With Ghost, I saw warnings about missing mail.from configuration. Ghost was falling back to a generated sender address, and email delivery was being temporarily rejected.

That made sense once I thought about it. Mail servers take sender identity very seriously. If an application sends mail from an address that is not properly configured or authenticated, providers may reject it. A working app is not enough. The sender address, SMTP credentials, DNS records, SPF, DKIM, and DMARC all affect whether email is accepted.

This was a reminder that running a real application means supporting all the services around it.

A website is not just a web server. It may also need a database, email provider, DNS configuration, backups, monitoring, storage, certificates, and a reverse proxy. Each piece has its own failure modes.

Self-hosting makes those dependencies visible.

Reverse Proxy Setup Is Part of the Application

The reverse proxy is another piece that beginners often underestimate.

At first, it seems like a simple traffic router. Requests arrive on a domain, and the proxy forwards them to the correct container and port.

But the reverse proxy does more than pass traffic around.

It handles HTTPS. It manages certificates. It preserves or modifies headers. It controls redirects. It may affect how the application understands its public URL. It can influence whether admin pages, login flows, assets, and APIs behave correctly.

A small proxy misconfiguration can create strange symptoms. The app might work locally but fail through the domain. It might redirect to the wrong URL. It might generate HTTP links when it should generate HTTPS links. It might load the homepage but break the admin panel.

That is why I have learned to treat the reverse proxy as part of the app’s deployment, not as an afterthought.

If the container is running but the proxy is wrong, the deployment is not finished.

Friction Is Where the Learning Happens

None of these problems were especially fun in the moment.

When a container fails, I want it fixed. When a migration breaks, I want the site back online. When email fails to send, I do not want a lecture on DNS records. I want the message delivered.

But after the frustration passes, the value becomes clear.

Docker taught me to separate runtime from state.

Ghost migrations taught me to respect database compatibility.

Email configuration taught me that production readiness is more than a working homepage.

Reverse proxy setup taught me that networking details matter.

Persistent volumes taught me that old decisions can stay with you long after the container has been replaced.

These are lessons I would not learn as deeply if everything were hidden behind a managed dashboard.

That does not mean managed services are bad. They are often the right choice. But when I self-host, I get a clearer view of how the pieces actually fit together.

Why I Still Prefer Self-Hosting

I still prefer self-hosting because it gives me agency.

If something breaks, I can investigate it. If I want to change something, I can change it. If I want to migrate, back up, rebuild, or inspect the system, I have options.

There is also a quiet confidence that comes from working through problems yourself. The next error is still annoying, but it is less intimidating when you have already solved a few before it.

Self-hosting has made me more patient. It has made me better at reading logs. It has made me more careful with upgrades. It has made me think more seriously about backups, compatibility, and configuration.

Most importantly, it has helped me understand that ownership is not just having control when things work. Ownership also means accepting responsibility when things break.

That responsibility is part of the deal.

And for me, it is still worth it.

Final Thoughts

Self-hosting is not effortless. It is not always cheaper. It is not always the fastest path from idea to finished product.

But it is rewarding.

There is something satisfying about building a system, breaking it, understanding it, fixing it, and making it better. There is something valuable about knowing how your own tools work. There is something meaningful about owning your infrastructure instead of only renting convenience from someone else.

My self-hosting journey has included Docker issues, Ghost migration errors, database compatibility problems, email configuration, reverse proxy setup, and plenty of small lessons along the way.

I am sure there will be more problems ahead.

But that is part of why I keep doing it.

Because every problem makes the next one easier to understand.

And when the site is finally running, the logs are clean, the database is healthy, the proxy is routing correctly, and the emails are sending, it feels different.

It is not just online.

It is mine.