Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .env.dev.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ DEPLOY_DOMAIN=localhost
SERVER_IP=127.0.0.1
LE_EMAIL=dev@example.com

# Git provider (at least one of GitHub or Gitea must be configured)
# GitHub App (see https://devpu.sh/gh-app)
GITHUB_APP_ID=
GITHUB_APP_NAME=
GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
GITHUB_APP_WEBHOOK_SECRET=
GITHUB_APP_CLIENT_ID=
GITHUB_APP_CLIENT_SECRET=
# GITHUB_APP_ID=
# GITHUB_APP_NAME=
# GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
# GITHUB_APP_WEBHOOK_SECRET=
# GITHUB_APP_CLIENT_ID=
# GITHUB_APP_CLIENT_SECRET=

# Gitea
# GITEA_WEBHOOK_SECRET=

# Email (only needed for email login/invites)
EMAIL_SENDER_ADDRESS=
Expand Down
16 changes: 10 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ LE_EMAIL=admin@example.com
CERT_CHALLENGE_PROVIDER=default # default|cloudflare|route53|gcloud|digitalocean|azure
# CF_DNS_API_TOKEN=

# Git provider (at least one of GitHub or Gitea must be configured)
# GitHub App (see https://devpu.sh/gh-app)
GITHUB_APP_ID=
GITHUB_APP_NAME=
GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
GITHUB_APP_WEBHOOK_SECRET=
GITHUB_APP_CLIENT_ID=
GITHUB_APP_CLIENT_SECRET=
# GITHUB_APP_ID=
# GITHUB_APP_NAME=
# GITHUB_APP_PRIVATE_KEY= # PEM content, use \n for newlines
# GITHUB_APP_WEBHOOK_SECRET=
# GITHUB_APP_CLIENT_ID=
# GITHUB_APP_CLIENT_SECRET=

# Gitea
# GITEA_WEBHOOK_SECRET=

# Email
EMAIL_SENDER_ADDRESS=
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ An open-source and self-hostable alternative to Vercel, Render, Netlify and the

## Key features

- **Git-based deployments**: Push to deploy from GitHub with zero-downtime rollouts and instant rollback.
- **Git-based deployments**: Push to deploy from GitHub or Gitea with zero-downtime rollouts and instant rollback.
- **Multi-language support**: Python, Node.js, PHP... basically anything that can run on Docker.
- **Environment management**: Multiple environments with branch mapping and encrypted environment variables.
- **Real-time monitoring**: Live and searchable build and runtime logs.
Expand All @@ -26,7 +26,7 @@ See [devpu.sh/docs](https://devpu.sh/docs) for installation, configuration, and

- **Server**: Ubuntu 20.04+ or Debian 11+ with SSH access and sudo privileges. A [Hetzner CPX31](https://devpu.sh/docs/guides/create-hetzner-server) works well.
- **DNS**: We recommend [Cloudflare](https://cloudflare.com).
- **GitHub account**: You'll create a GitHub App for login and repository access.
- **GitHub account**: You'll create a GitHub App for login and repository access. Gitea instances can also be connected via Personal Access Tokens.
- **Email provider**: A [Resend](https://resend.com) account or SMTP credentials for login emails and invitations.

## Quickstart
Expand Down Expand Up @@ -125,6 +125,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for codebase structure.
| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook secret. |
| `GITHUB_APP_CLIENT_ID` | GitHub OAuth client ID. |
| `GITHUB_APP_CLIENT_SECRET` | GitHub OAuth client secret. |
| `GITEA_WEBHOOK_SECRET` | Shared secret for verifying Gitea webhook payloads (optional, required if using Gitea). |
| `APP_HOSTNAME` | Domain for the app (e.g., `example.com`). |
| `DEPLOY_DOMAIN` | Domain for deployments (wildcard root). No default—set explicitly (e.g., `deploy.example.com`). |
| `LE_EMAIL` | Email for Let's Encrypt notifications. |
Expand Down
18 changes: 17 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class Settings(BaseSettings):
app_name: str = "/dev/push"
app_description: str = (
"An open-source platform to build and deploy any app from GitHub."
"An open-source platform to build and deploy any app from a Git repository."
)
url_scheme: str = "https"
app_hostname: str = ""
Expand All @@ -22,6 +22,7 @@ class Settings(BaseSettings):
github_app_webhook_secret: str = ""
github_app_client_id: str = ""
github_app_client_secret: str = ""
gitea_webhook_secret: str = ""
google_client_id: str = ""
google_client_secret: str = ""
resend_api_key: str = ""
Expand Down Expand Up @@ -78,6 +79,21 @@ class Settings(BaseSettings):

model_config = SettingsConfigDict(extra="ignore")

@property
def has_github(self) -> bool:
return bool(
self.github_app_id
and self.github_app_name
and self.github_app_private_key
and self.github_app_webhook_secret
and self.github_app_client_id
and self.github_app_client_secret
)

@property
def has_gitea(self) -> bool:
return bool(self.gitea_webhook_secret)

@property
def allow_custom_cpu(self) -> bool:
return self.default_cpus is not None and self.max_cpus is not None
Expand Down
7 changes: 6 additions & 1 deletion app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ def get_github_installation_service() -> GitHubInstallationService:


@lru_cache
def get_github_oauth_client() -> OAuth:
def get_github_oauth_client() -> OAuth | None:
settings = get_settings()
if not settings.github_app_client_id or not settings.github_app_client_secret:
return None

oauth = OAuth()
oauth.register(
"github",
Expand Down Expand Up @@ -534,6 +537,8 @@ def time_ago_filter(value):
templates.env.globals["app_description"] = settings.app_description
templates.env.globals["get_flashed_messages"] = get_flashed_messages
templates.env.globals["toaster_header"] = settings.toaster_header
templates.env.globals["has_github"] = settings.has_github
templates.env.globals["has_gitea"] = settings.has_gitea
templates.env.filters["time_ago"] = time_ago_filter
templates.env.globals["get_access"] = get_access
templates.env.globals["is_superadmin"] = is_superadmin
Expand Down
4 changes: 4 additions & 0 deletions app/forms/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,10 @@ class ProjectGeneralForm(StarletteForm):
avatar = FileField(_l("Avatar"))
delete_avatar = BooleanField(_l("Delete avatar"), default=False)
repo_id = IntegerField(_l("Repo ID"), validators=[DataRequired()])
repo_full_name = HiddenField()
repo_provider = HiddenField()
repo_base_url = HiddenField()
connection_id = HiddenField()

def validate_avatar(self, field):
if field.data:
Expand Down
17 changes: 17 additions & 0 deletions app/forms/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,20 @@ class UserOAuthAccessRevokeForm(StarletteForm):
choices=["github", "google"],
)
submit = SubmitField(_l("Disconnect"))


class GiteaConnectionCreateForm(StarletteForm):
base_url = StringField(
_l("Instance URL"),
validators=[DataRequired(), Length(max=512)],
)
token = StringField(
_l("Personal access token"),
validators=[DataRequired(), Length(max=512)],
)
submit = SubmitField(_l("Connect"))


class GiteaConnectionDeleteForm(StarletteForm):
connection_id = HiddenField(validators=[DataRequired()])
submit = SubmitField(_l("Remove"))
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from db import get_db, AsyncSessionLocal
from dependencies import get_current_user, TemplateResponse
from models import User, Team, Deployment, Project
from routers import auth, project, github, google, team, user, event, admin
from routers import auth, project, github, gitea, google, team, user, event, admin
from services.loki import LokiService

settings = get_settings()
Expand Down Expand Up @@ -179,6 +179,7 @@ async def root(
app.include_router(user.router)
app.include_router(project.router)
app.include_router(github.router)
app.include_router(gitea.router)
app.include_router(google.router)
app.include_router(team.router)
app.include_router(event.router)
Expand Down
73 changes: 73 additions & 0 deletions app/migrations/versions/a1b2c3d4e5f6_gitea_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Add Gitea provider support

Revision ID: a1b2c3d4e5f6
Revises: 4fe4c96ad3dd
Create Date: 2026-02-22 00:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, Sequence[str], None] = "4fe4c96ad3dd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

repo_provider_enum = sa.Enum("github", "gitea", name="repo_provider")


def upgrade() -> None:
repo_provider_enum.create(op.get_bind(), checkfirst=True)

op.create_table(
"gitea_connection",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, index=True),
sa.Column("base_url", sa.String(512), nullable=False),
sa.Column("username", sa.String(255), nullable=False),
sa.Column("token", sa.String(2048), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "base_url", name="uq_gitea_connection_user_url"),
)

# Project: add repo_provider, repo_base_url, gitea_connection_id; make github_installation_id nullable
op.add_column("project", sa.Column("repo_provider", repo_provider_enum, nullable=True))
op.add_column("project", sa.Column("repo_base_url", sa.String(512), nullable=True))
op.add_column(
"project",
sa.Column("gitea_connection_id", sa.Integer(), sa.ForeignKey("gitea_connection.id"), nullable=True, index=True),
)

op.execute("UPDATE project SET repo_provider = 'github', repo_base_url = 'https://github.com'")

op.alter_column("project", "repo_provider", nullable=False)
op.alter_column("project", "repo_base_url", nullable=False)
op.alter_column("project", "github_installation_id", nullable=True)

# Deployment: add repo_provider, repo_base_url
op.add_column("deployment", sa.Column("repo_provider", repo_provider_enum, nullable=True))
op.add_column("deployment", sa.Column("repo_base_url", sa.String(512), nullable=True))

op.execute("UPDATE deployment SET repo_provider = 'github', repo_base_url = 'https://github.com'")

op.alter_column("deployment", "repo_provider", nullable=False)
op.alter_column("deployment", "repo_base_url", nullable=False)


def downgrade() -> None:
op.drop_column("deployment", "repo_base_url")
op.drop_column("deployment", "repo_provider")

op.alter_column("project", "github_installation_id", nullable=False)
op.drop_column("project", "gitea_connection_id")
op.drop_column("project", "repo_base_url")
op.drop_column("project", "repo_provider")

op.drop_table("gitea_connection")

repo_provider_enum.drop(op.get_bind(), checkfirst=True)
67 changes: 63 additions & 4 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,42 @@ class TeamInvite(Base):
inviter: Mapped[User] = relationship()


class GiteaConnection(Base):
__tablename__: str = "gitea_connection"

id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True)
base_url: Mapped[str] = mapped_column(String(512), nullable=False)
username: Mapped[str] = mapped_column(String(255), nullable=False)
_token: Mapped[str] = mapped_column("token", String(2048), nullable=False)
created_at: Mapped[datetime] = mapped_column(default=utc_now)
updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now)

# Relationships
user: Mapped[User] = relationship()
projects: Mapped[list["Project"]] = relationship(
back_populates="gitea_connection"
)

__table_args__ = (
UniqueConstraint("user_id", "base_url", name="uq_gitea_connection_user_url"),
)

@property
def token(self) -> str:
fernet = get_fernet()
return fernet.decrypt(self._token.encode()).decode()

@token.setter
def token(self, value: str):
fernet = get_fernet()
self._token = fernet.encrypt(value.encode()).decode()

@override
def __repr__(self):
return f"<GiteaConnection {self.base_url}>"


class GithubInstallation(Base):
__tablename__: str = "github_installation"

Expand Down Expand Up @@ -317,17 +353,28 @@ class Project(Base):
)
name: Mapped[str] = mapped_column(String(100), index=True)
has_avatar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
repo_provider: Mapped[str] = mapped_column(
SQLAEnum("github", "gitea", name="repo_provider"),
nullable=False,
default="github",
)
repo_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
repo_base_url: Mapped[str] = mapped_column(
String(512), nullable=False, default="https://github.com"
)
repo_status: Mapped[str] = mapped_column(
SQLAEnum(
"active", "deleted", "removed", "transferred", name="project_github_status"
),
nullable=False,
default="active",
)
github_installation_id: Mapped[int] = mapped_column(
ForeignKey("github_installation.installation_id"), nullable=False, index=True
github_installation_id: Mapped[int | None] = mapped_column(
ForeignKey("github_installation.installation_id"), nullable=True, index=True
)
gitea_connection_id: Mapped[int | None] = mapped_column(
ForeignKey("gitea_connection.id"), nullable=True, index=True
)
environments: Mapped[list[dict[str, str]]] = mapped_column(
JSON, nullable=False, default=list
Expand All @@ -354,7 +401,10 @@ class Project(Base):
team_id: Mapped[str] = mapped_column(ForeignKey("team.id"), index=True)

# Relationships
github_installation: Mapped[GithubInstallation] = relationship(
github_installation: Mapped[GithubInstallation | None] = relationship(
back_populates="projects"
)
gitea_connection: Mapped[GiteaConnection | None] = relationship(
back_populates="projects"
)
deployments: Mapped[list["Deployment"]] = relationship(back_populates="project")
Expand Down Expand Up @@ -736,8 +786,16 @@ class Deployment(Base):
String(32), primary_key=True, default=lambda: token_hex(16)
)
project_id: Mapped[str] = mapped_column(ForeignKey("project.id"), index=True)
repo_provider: Mapped[str] = mapped_column(
SQLAEnum("github", "gitea", name="repo_provider", create_type=False),
nullable=False,
default="github",
)
repo_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
repo_full_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
repo_base_url: Mapped[str] = mapped_column(
String(512), nullable=False, default="https://github.com"
)
environment_id: Mapped[str] = mapped_column(String(8), nullable=False)
branch: Mapped[str] = mapped_column(String(255), index=True)
commit_sha: Mapped[str] = mapped_column(String(40), index=True)
Expand Down Expand Up @@ -798,9 +856,10 @@ class Deployment(Base):

def __init__(self, *args, project: "Project", environment_id: str, **kwargs):
super().__init__(project=project, environment_id=environment_id, **kwargs)
# Snapshot repo, config, environments and env_vars from project at time of creation
self.repo_provider = project.repo_provider
self.repo_id = project.repo_id
self.repo_full_name = project.repo_full_name
self.repo_base_url = project.repo_base_url
self.config = project.config
environment = project.get_environment_by_id(environment_id)
self.env_vars = project.get_env_vars(environment["slug"]) if environment else []
Expand Down
1 change: 1 addition & 0 deletions app/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ async def auth_login(
else "auth/pages/login.html",
context={
"form": form,
"has_github_login": settings.has_github,
"has_google_login": bool(
settings.google_client_id and settings.google_client_secret
),
Expand Down
Loading