A Go + HTMX boilerplate with all the essentials for your SaaS, AI tools, or web apps. Start earning online quickly without the hassle.
🎯 The goal of this project is to build the most comprehensive Go-centric OSS starter boilerplate to ship projects fast.
Check out the video above for a rapid walkthrough of the project! 🏂
This started as a fork of pagoda, for which I am super grateful! Big shoutout to Mike Stefanello and team!
Make sure you have make
and Golang installed on your machine.
To get up and running with GoShip:
# The below command will:
# - set up the postgres/redis/mailer containers
# - build the JS/CSS assets
# - seed the DB with test users
# - start the project in watch mode
make init
# Running init will fully scrap your state and start with fresh new containers.
# After running `make init` the first time, just use the below for everyday work.
make watch
For in-depth info on the architecture of the project, please see the mikestefanello/pagoda repo. There are some key differences, but since this was originally a fork, 99% of it still applies. I am working on creating clear and actionable documentation, but that is quite time-consuming, so don't hold your socks.
Build the same rich interfaces you would build with Javascript frameworks, but with HTML and Go. Limit the number of tools you use. Develop rapidly.
Well, I noticed that there were none for Go. Now, I know most Go folks like to build it all themselves. And while I love doing that myself, I have many project ideas for which I just want to build that specific project, not the entire infra surrounding it, like auth, notifications, payments, file uploads etc. This project has served me well in bringing to production many projects so far. It has evolved far beyond what I originally planned for, though there is still so much potentional to expand on and implement for.
If you'd like a no-nonesense (or not too much?) starter kit to get your next project to production ASAP, while also using awesome technologies like Go, you've found a suitable starting point!
Warning alert! this project is in active development as I am adding things after first trying them out in prod for Goship, a relationship app to grow your couple. Note that I would welcome any help to develop this boilerplate ❤️.
See goship.run.
|-- cmd
| |-- web # Web server
| |-- worker # Async worker
| |-- seed # Seeder
|-- config # Config files where the non-secret config vars are stored and the config go struct is defined
|-- pkg # Package imports
| |-- context # Context package to handle context across the app
| |-- controller # Controller package to handle requests and responses
| |-- domain # Domain objects that are used throughout the app, these should be specific to your app/project
| |-- funcmap # Custom template functions
| |-- htmx # HTMX lifecycle helpers
| |-- middleware # Middleware for the app
| |-- repos # Repositories
| |-- routes # Think of these as the controllers in a traditional MVC framework
| |-- services # Services on the Container struct
| |-- tasks # Task definitions
| |-- tests # Utility functions for testing
| |-- types # Struct types
|-- templates # HTML templates
|-- ent # Ent ORM, contains the schema for the DB as well as the generated code from the schema. Always commit this to git.
# Everything else is not Go-specific
|-- deploy.yml # Kamal deployment file
|-- docker-compose.yml # Docker compose file for running the project locally with Docker Desktop
|-- e2e_tests # Playwright E2E tests
|-- scripts # Useful scripts
|-- static # Static files
|-- javascript # Any javascript app can be dropped here. JS and CSS will be built and bundled into a single file. It is currently set up solely for Vanilla JS and Svelte.
|-- build.mjs # Build script for the JS defined in `./javascript`
|-- .env # Secret environment variables
|-- .kamal # Kamal hooks you can use to run commands in the project during deployment
|-- .github # Github actions and secrets
|-- .gitignore # Files to ignore when committing
|-- tailwind.config.js # Tailwind config
|-- tsconfig.json # Typescript config
|-- Procfile # Defines the commands to run the project in watch mode
|-- service-worker.js # Service worker for the PWA
|-- pwabuilder-ios-wrapper # PWA iOS wrapper. Use as a guide for push notifications.
The Makefile is the main entry point for the project. It is used to build the project, run the project, and deploy the project.
The following commands are the most useful ones:
make init # Initializes the project
make watch # Runs the project in watch mode, rebuilding assets as you go (JS, CSS, Templ, etc)
make test # Runs the tests
make e2eui # Runs the interactive e2e tests with Playwright #
make cover # Shows a Go coverage report of the tests
# DB specific commands
make ent-new name=YourModelName # Creates a new ent schema file
make makemigrations # Creates a new migration file
make ent-gen # Generates the ent code from the schema
make migrate # Applies migrations
make inspecterd # Shows you a view of all your tables in a UI
make schema
# Docker commands
make up # Starts the docker containers
make down # Stops the docker containers
make down-volume # Stops the docker containers and removes the volumes
make reset # Stops the docker containers and removes the volumes, then rebuilds the docker containers
# Assets
make build-js # Builds the JS assets
make watch-js # Watches the JS assets and rebuilds them on change
make build-css # Builds the CSS assets
make watch-css # Watches the CSS assets and rebuilds them on change
# Worker commands
make worker # Starts the worker
make worker-ui # Will open the terminal to the asynq worker UI
# Stripe (payments)
make stripe-webhook # Sets up a webhook for stripe for local testing
make help # Shows all the commands you can run
For in-depth info on the architecture of the project, please see the mikestefanello/pagoda repo. There are some key differences, but since this was originally a fork, 99% of it still applies.
The most important aspects to note are:
- The
Container
struct is instantiated when the app starts up and is used to pass dependencies around the app, specifically core services likeLogger
,Database
,ORM
,Cache
, etc. - Routes are defined in
routes/routes.go
and are registered to theEcho
framework. Generally, any logic that alters the DB should be done in therepos
layer so that it is easily testable, and can be used by other routes. A route will generally have aComponent
, which is a Templ component defined intemplates/pages/
that represents the view.
The current options are:
- Standalone Postgres DB (which you can host anywhere, including locally with Docker)
- Embedded SQLite DB (which is great for small projects and local development)
To get a look at what tables are available to start off, you can run
make schema
or go to ent/schema
and see the declared schemas. Note that ent generates a lot of code. Do not remove it from git. In fact, make sure to keep it there.
To create a new schema, do:
make ent-new name=YourSchemaName
Then generate the migrations
make makemigrations
Then generate the ent generated code to interact with your new schema in Go:
make ent-gen
To apply the migrations, either run make migrate
or do a make reset
to start from scratch (often times easier, and your test DB should be treated as disposable).
Create a new file in routes/
and add your route. A route is a standard Echo handler with some added goodies. Once you've added handlers for your route, you can hook it up to the router in routes/routes.go
, where the route should be registered to be reachable from the web.
Following an action (POST/DELETE/GET/etc), a msg can be shown to the user. For example, a success message can shown with msg.Success("An email confirmation was sent!")
upon user registration. The following message types are currently available:
- success
- info
- warning
- danger
See pkg/repos/msg/msg.go
for more info.
There is a realtime
route that is setup to handle SSE connections to any client desiring real-time data. Realtime data is sent in "notifications" which are just custom events with a notification type, some data, and a profile id. The NotifierRepo
handles subscribing the client to the right channels and pushing new notifications to the client. Notifications can be stored in the DB in case the client is offline and needs to be picked up later when they reconnect - these will be shown in the notification center UI.
Methods for interacting with notifications:
PublishNotification
to send a notification to a user. This can optionally store the notification in the DB.MarkNotificationUnread
to mark a notification as unread.MarkNotificationRead
to mark a notification as read.DeleteNotification
to delete a notification.GetNotifications
to get all notifications for a user.
Note that actual storage of notifications in the DB is handled by NotificationStorageRepo
.
The NotificationSendPermissionRepo
handles the permission logic for sending notifications to a user. It is used to determine if a user has granted permission to send notifications to them and lives at pkg/repos/notifierrepo/permissions.go
.
You can mostly leave this alone, but if you need to add a new permission platform (e.g. a new push notification service), you may need to add a new permission here.
The PlannedNotificationsRepo
handles the logic for sending notifications at a planned time and lives at pkg/repos/notifierrepo/planned_notifications.go
. The repo does not send any notifications, but rather sets up the DB storage for scheduled notifications. It also contains a method to clean up old notifications. But both the sending and deletion methods need to be called as tasks. Two examples are the TypeAllDailyConvoNotifications
and TypeEmailUpdates
tasks, as well as the TypeDeleteStaleNotifications
task, which are commented out in the cmd/web/main.go
file.
The algorithm used to determine best time to send notifications is very primitive. Feel free to improve it! (or I will eventually, though it's low priority)
There are 2 push notification repos for different use cases:
PwaPushNotificationsRepo
: for sending push notifications to PWAs.FcmPushNotificationsRepo
: for sending push notifications to native Android and iOS apps.
Both have similar interfaces:
AddPushSubscription
: to add a new push subscription, triggered when the profile turns on PWA notifications in their profile settings.SendPushNotifications
: to send a push notification to a user. This is generally handled by theNotifierRepo
after storing a notification in the DB using thePublishNotification
method.DeletePushSubscriptionByEndpoint
: to delete a push subscription by endpoint.
The ProfileRepo
handles all the profile logic and lives at pkg/repos/profilerepo/profilerepo.go
. It contains basic CRUD methods for profiles, as well as some helper methods for getting friends, updating profile info, etc.
There is extensive "friendship" logic in the repo, which is currently not used in the app. It is left over from Chérie as a demo. Feel free to delete these methods if you don't need them!
GetFriends
: to get all friends for a profile. This is a demo as there is no friends feature in the app.AreProfilesFriends
: to check if two profiles are friends. This is a demo as there is no friends feature in the app.LinkProfilesAsFriends
: to link two profiles as friends. This is a demo as there is no friends feature in the app.UnlinkProfilesAsFriends
: to unlink two profiles as friends. This is a demo as there is no friends feature in the app.GetProfileByID
: to get a profile by ID.GetCountOfUnseenNotifications
: to get the count of unseen notifications for a profile.GetPhotosByProfileByID
: to get the photos for a profile by ID.GetProfilePhotoThumbnailURL
: to get the thumbnail URL for a profile's photo by ID.SetProfilePhoto
: to set the profile photo for a profile by ID.UploadPhoto
: to upload a photo for a profile by ID.UploadImageSizes
: to upload image sizes for a photo by ID.DeletePhoto
: to delete a photo by ID.DeleteUserData
: to delete a user's data by ID. This should be updated to delete all new models that may not cascade delete and is used in the settings to delete a user's data and account.IsProfileFullyOnboarded
: to check if a profile is fully onboarded. This is used in the onboarding flow to check if the profile has completed the onboarding process. Edit as needed. On startup, a non-onboarded profile is redirected to the onboarding page.
Note that a method EntProfileToDomainObject
is used to convert the ent profile object to a domain profile object, which is a more generic object that is used throughout the app. Generally, domain objects are preferred over ent objects as they are more generic and are not tied to a specific ORM.
The StorageClient
handles all the file storage logic and lives at pkg/repos/storage/storagerepo.go
. It uses minio under the hood to handle the file uploads with AWS S3 API, which means you can easily swap out the storage backend to any S3-compatible service.
The following methods are available:
CreateBucket
: to create a new bucket.UploadFile
: to upload a new file.DeleteFile
: to delete a file.GetPresignedURL
: to get a presigned URL for a file.GetImageObjectFromFile
: to get an image object from a file.GetImageObjectsFromFiles
: to get image objects from a list of files.
The SubscriptionsRepo
handles the subscription logic and lives at pkg/repos/subscriptions/subscriptions.go
. It uses Stripe under the hood to handle the subscription logic. If you'd like to see the stripe webhooks, they live at pkg/routes/payments.go
.
Note: currently, the only type of subscription implemented is a monthly subscription that is either paid or free. Feel free to expand on this!
The following methods are available:
CreateSubscription
: to create a new subscription.DeactivateExpiredSubscriptions
: to deactivate all expired monthly subscriptions.UpdateToPaidPro
: to update a subscription to the pro plan.UpdateToFree
: to update a subscription to the free plan.GetCurrentlyActiveProduct
: to get the currently active product for a profile.CancelWithGracePeriod
: to cancel a subscription with a grace period.CancelOrRenew
: to cancel a subscription or renew it.
There is a python script in scripts/regen_logo_images.py
that should be run when the logo in static/logo.png
is updated.
This will regenerate the logo assets for different app icons and the favicon. It will also regenerate the correct iOS and Android app icons and place them in the static/ios-wrapper/
and static/android-wrapper/
directories. Note that for iOS it will remove alpha transparency and make the background black (as apple requires).
cd scripts
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 scripts/regen_logo_images.py
Currently, tasks are run using asynq. This unfortunately requires redis to be running. This can make deployment a bit trickier as it means you will need at least 3 VPS with Kamal (except if I'm missing something), as you will need one for the web app, one for the worker, and one for the cache/queue. This is far from ideal for small projects, and pagoda's author decided to use backlite, a tool he created to use SQLite as the task queue. I have not gone around to pulling these changes in yet, and I am hesitant at this point as I have multiple projects running in prod, and only 1 VPS running a cache that is serving all my projects...which means that I don't have a huge incentive to add this in.
If you'd like to change asynq to backlite, you can refer to this pagoda PR to bring the changes in your goship instance.
While the project primarily uses HTMX, it also supports integrating JavaScript applications. The current build process creates two separate bundles:
- A single Vanilla JavaScript bundle
- A single Svelte bundle This approach allows you to incorporate JavaScript functionality alongside the HTMX-driven parts of your application. Here's how it works:
- The build.mjs script handles the bundling process for both Vanilla JS and Svelte components. -Each framework (Vanilla JS and Svelte) is compiled into its own single file bundle. -These bundles can be served to the frontend and used where needed in your application.
Note: While this method allows for easy integration, it does come with the trade-off of potentially large bundle sizes. Future improvements could involve optimizing the build process to create smaller, component-specific bundles for more efficient loading.
This setup provides flexibility to use JavaScript frameworks alongside HTMX, leveraging the strengths of both approaches in different parts of your application.
Note that any JS framework could be used.
Note: Svelte is used for highly interactive components, although I've come to regret this as it is a large framework to bundle and slow down the initial page load. In the future, I plan to remove Svelte and only use HTMX for all components. This would not impact the ability to drop in any JS app, however.
TODO: the test file can be found at e2e_tests/tests/goship.spec.ts
and is currently still the one from chérie...I will update it soon!
You can run the Playwright tests with:
make e2eui
NOTE: on older/slower machines, the tests may time out. If so, you can increase the timeout in the test file. I was facing that issue when testing locally on a 2014 Macbook Pro, though have not faced it since running the tests on my M2 Mac. I am no playwright expert too, so perhaps I am missing something.
I currently only use Kamal for deployment. Should you want to contribute in adding other deployment methods, please create a subdirectory in deploy
and add it there, so that it's well organized.
First, make sure all your env vars in the Kamal file deploy/kamal/deploy.yml
are correct. All your vars should be set either in:
config/config.yml
: only non-secret onesdeploy/kamal/deploy.yml
: only non-secret ones.env
: all secret vars
Then, set the IP of the server host in deploy/kamal/deploy.yml
, as well as your image and registry details. Read up on the kamal documentation if you get stuck anywhere here.
The below command will install docker, build your image, push it to your registry, and then pull it on your remote VPS. If you set up any accessory (cache, standalone DB that is not hosted etc), these will also be deployed.
kamal setup -c deploy/kamal/deploy.yml
At this point, your project should be live, and if 128.0.0.1111
is the IP of your VPS, entering that IP in the search bar on your browser should bring up your site.
Hop into your VPS console.
mkdir -p /letsencrypt && touch /letsencrypt/acme.json && chmod 600 /letsencrypt/acme.json
Then locally, run
kamal traefik reboot -c deploy/kamal/deploy.yml
Your site should now have TLS enabled and you should see the lock icon the search bar.
For reference, the above procedure was taken from this Kamal issue.
There are some sample firewall scripts in config/firewalls/
to help you get started. They make use of ufw
so make sure that is installed on your system.
The worker firewall should block all ports by default except for SSH and internal network traffic.
The web app firewall should block all ports by default except for SSH, HTTPS, and internal network traffic.
The accessories firewall should block all ports by default, though if using Asynq, you should allow 8080 (Asynq UI) to your specific IP.
Improve the experience with handling config and environment variables. Currently, there is an .env
file with secrets, which can be of the form PAGODA_STORAGE_S3ACCESSKEY=123
and then in config.yml
it is under:
pagoda:
storage:
s3accesskey: 123
And in config.go
it is defined as:
type Config struct {
Pagoda struct {
Storage struct {
S3accesskey string
}
}
}
This is fine for simple cases but can quickly be confusing. It would be nice to have a more robust env management system, perhaps one that can auto-generate env vars for you in the .env
and config.yml
files, so that no human error is introduced, leading the developer to confusion trying to figure out what is going on.
This is a CLI command that will generate a route, model, and view for you. It's just at the idea stage and would be a great feature to have. A lot of time goes into writing boilerplate code for each new route. Ideally, it supports generating templ/htmx routes and JSON routes.
Example:
goship generate scaffold Post title:string content:text
// ent/schema/post.go
package schema
type Post struct {
ent.Schema
}
func (Post) Mixin() []ent.Mixin {
return []ent.Mixin{
TimeMixin{},
}
}
func (Post) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
field.String("content"),
}
}
func (UsPoster) Edges() []ent.Edge {
return nil
}
// app/controllers/post_controller.go
package routes
type postRoute struct {}
func NewPostRoute(
ctr controller.Controller,
) postRoute {
return postRoute{
ctr: ctr,
}
}
func (p *postRoute) Index(ctx echo.Context) {
// List all posts
}
func (p *postRoute) Show(ctx echo.Context) {
// Show a specific post
}
func (p *postRoute) New(ctx echo.Context) {
// Render form to create new post
}
func (p *postRoute) Create(ctx echo.Context) {
// Logic to create a new post
}
func (p *postRoute) Edit(ctx echo.Context) {
// Render form to edit post
}
func (p *postRoute) Update(ctx echo.Context) {
// Logic to update a post
}
func (p *postRoute) Destroy(ctx echo.Context) {
// Logic to delete a post
}
The routes will be automatically added to the router:
postRoute := NewPostRoute(ctr)
g.GET("/posts", postRoute.Index).Name = "posts.index"
g.GET("/posts/:id", postRoute.Show).Name = "posts.show"
g.GET("/posts/new", postRoute.New).Name = "posts.new"
g.POST("/posts", postRoute.Create).Name = "posts.create"
g.GET("/posts/:id/edit", postRoute.Edit).Name = "posts.edit"
g.POST("/posts/:id", postRoute.Update).Name = "posts.update"
g.DELETE("/posts/:id", postRoute.Destroy).Name = "posts.destroy"
// templates/posts.templ
package pages
import (
"github.com/mikestefanello/pagoda/pkg/controller"
"github.com/mikestefanello/pagoda/pkg/types"
"github.com/mikestefanello/pagoda/templates/components"
)
templ PostsIndex(page *controller.Page) {
}
templ PostsShow(page *controller.Page) {
}
templ PostsNew(page *controller.Page) {
}
templ PostsEdit(page *controller.Page) {
}
templ PostsCreate(page *controller.Page) {
}
templ PostsUpdate(page *controller.Page) {
}
templ PostsDestroy(page *controller.Page) {
}
// types/post.go
package types
type Post struct {
Title string
Content string
}